diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml index 0c1f35e6bc..231e148f9d 100644 --- a/agent_context/MAP.yaml +++ b/agent_context/MAP.yaml @@ -11,7 +11,7 @@ topics: aliases: [simulation system, simulation manager, Newton physics backend, motion module, sim motion, camera attachment, 运动能力模块, 仿真系统, 仿真管理器] keywords: [SimulationManager, SimulationManagerCfg, RenderCfg, DenoisingCfg, DenoisingMode, DLSSCfg, NRDCfg, DLSS, NRD, frame_time_delta_ms, explicit physics stepping, arena, DexSim, PhysicsBackendCfg, - DefaultPhysicsCfg, NewtonPhysicsCfg, VisualObject, VisualObjectCfg, UsdSourceCfg, source_prim_path, add_visual_object, 纯渲染对象, AutoSolverCfg, DexUniSolverCfg, DexUni, runtime control, deformable, particle set, + 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] paths: [topics/simulation-system/simulation-system.md] @@ -24,6 +24,9 @@ topics: - 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 - embodichain/lab/sim/common.py - embodichain/lab/sim/_startup_summary.py - embodichain/lab/sim/_runtime_controls.py @@ -34,7 +37,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/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/, 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 @@ -422,7 +425,7 @@ topics: title: Data Assets aliases: [data assets, data path, asset download, 数据资产, 资产下载, 数据路径] keywords: [get_data_path, get_data_class, EmbodiChainDataset, TabletopFourBases, demo assets, EMBODICHAIN_DATA_ROOT, EMBODICHAIN_DATASET_ROOT, - CATEGORY_MODULES, download lock, get_registry] + CATEGORY_MODULES, download lock, get_registry, DefaultMaterials, list_default_materials, get_default_material_info] paths: [topics/data-assets/data-assets.md] source_of_truth: - embodichain/data/dataset.py @@ -430,7 +433,11 @@ topics: - embodichain/data/download.py - embodichain/data/__init__.py - embodichain/data/assets/__init__.py - watch_paths: [embodichain/data/, embodichain/cli/main.py, tests/data/] + - embodichain/data/assets/materials.py + - embodichain/data/default_materials.py + - embodichain/data/default_materials.json + - embodichain/_material_catalog.py + watch_paths: [embodichain/data/, embodichain/_material_catalog.py, embodichain/cli/main.py, tests/data/] related_topics: [gen-sim, robot-system, data-pipeline] status: active - id: data-pipeline diff --git a/agent_context/topics/data-assets/data-assets.md b/agent_context/topics/data-assets/data-assets.md index ff768dd936..fecb2fcb29 100644 --- a/agent_context/topics/data-assets/data-assets.md +++ b/agent_context/topics/data-assets/data-assets.md @@ -12,6 +12,8 @@ Online sampling and demonstration persistence belong to | Cache roots / mirror prefix | `embodichain/data/constants.py` | | Locked download and ZIP integrity | `embodichain/data/dataset.py`: `EmbodiChainDataset`, `_dataset_download_lock()` | | CLI list/download | `embodichain/data/download.py`: `CATEGORY_MODULES`, `get_registry()`, `download_asset()` | +| Default material catalog / provenance | `embodichain/_material_catalog.py` → `embodichain/data/default_materials.py`; `embodichain/data/default_materials.json` | +| Default material archive | `embodichain/data/assets/materials.py`: `DefaultMaterials` | | Dataset class visibility | `embodichain/data/__init__.py`, `embodichain/data/assets/__init__.py` | | Pretrained policy bundles | `embodichain/data/assets/policy_assets.py`: `download_pretrained_policy()` | | Unified CLI registration | `embodichain/cli/main.py`: `embodichain data` | @@ -44,6 +46,8 @@ Keep the lock outside the directory repair may delete. POSIX uses `flock`; Windows uses a polled byte lock. Direct Open3D subclasses do not automatically receive the project's locking/repair behavior. +The CLI honors an asset class's optional `cache_prefix` when checking readiness +and ensuring extraction; otherwise the class name is the cache entry. The CLI imports the fixed `CATEGORY_MODULES` and finds `DownloadDataset` subclasses defined in each module. Adding an asset module alone does not add a CLI category. `get_registry()` also appends public classes from @@ -72,6 +76,32 @@ The registered descriptor respects the configured host and falls back to the official Hugging Face dataset. The scene tutorial downloads by asset reference by default and accepts local exports through `--source`. +## Default material resources + +`list_default_materials()` and `get_default_material_info()` are exported from +`embodichain.data` and query wheel-packaged metadata without a World or download. +The shared implementation lives in `_material_catalog.py` so CLI catalog queries +can avoid importing the data package's Open3D/download dependencies. Preserve the +public data exports and one catalog implementation. `setup.py` must include +that catalog explicitly. `DefaultMaterials` uses a +revision-specific cache prefix and the shared dataset lock, verifies archive +SHA256 and member digests, and repairs extraction from the verified archive. +Its data CLI registration follows the existing materials category. + +The same catalog includes six built-in parameter presets with their own revision, +native parameters and no texture size. Metadata distinguishes `source` and +`requires_download`; only resource entries belong to the `DefaultMaterials` +archive. The runtime resolves built-ins directly from wheel metadata before any +dataset construction. Keep their definitions out of the upstream ZIP checksum. + +The original DexSim archive is currently on its internal resource server; an +explicit `EMBODICHAIN_DOWNLOAD_PREFIX` selects the catalog's versioned mirror +path. Do not substitute an unverified public URL or depend on a DexSim source +checkout. Preserve upstream manifests, relative auxiliary texture dependencies +and attribution. The material runtime stages JSON only and does not copy PNGs; +see [visual materials](../simulation-system/materials.md) for renderer, override +and native ownership rules. + ## Pretrained policy bundles `eval-policy --pretrained` reads the official model index at a pinned HF commit, diff --git a/agent_context/topics/randomization/randomization.md b/agent_context/topics/randomization/randomization.md index 57ea084817..3d22d66a20 100644 --- a/agent_context/topics/randomization/randomization.md +++ b/agent_context/topics/randomization/randomization.md @@ -62,6 +62,13 @@ randomization remains active. See the owning - Camera extrinsics auto-detect the representation and frame: if `extrinsics.eye` is set → look-at mode (eye/target/up perturbation via `look_at`); otherwise → pose mode (pos/euler perturbation via `set_local_pose`), with `extrinsics.parent` selecting parent-relative pose and `parent=None` selecting arena-frame pose. - `set_rigid_object_visual_material` is deterministic (not random) but uses the same functor mechanism for fixed material assignment at reset. +- Visual material randomization preserves selected environments. Shared material + instances require a full-environment selection and are mutated once per call; + partial shared selections fail before mutation. Empty selections are no-ops. + Solid and library tiers apply configured scalar PBR ranges. Texture pools use + normalized source paths and remain bounded after initialization. See + [material identity and lifetime](../simulation-system/materials.md) for + configuration conversion, instance reuse and topology replay. - Light randomization applies the **same values across all envs** (documented limitation). - HDR indirect-lighting events inform the environment's automatic native-ground visibility; emissive mode does not. See the deployment-owned diff --git a/agent_context/topics/simulation-system/lifecycle.md b/agent_context/topics/simulation-system/lifecycle.md index 7031d6320e..e696ada674 100644 --- a/agent_context/topics/simulation-system/lifecycle.md +++ b/agent_context/topics/simulation-system/lifecycle.md @@ -12,7 +12,8 @@ readiness; `spawn/scene.py` owns declaration coordination. The ordering is: native parents. Resolve/configure sources and commit Spawn topology. 2. Apply Default articulation root properties before Direct GPU buffers capture them. Delegate runtime setup to `PhysicsBackend.prepare_spawn_runtime()`. -3. Bind declared facades in place; publish their state through +3. Replay the current per-segment [material assignments](materials.md) captured + before rebuilding, then bind declared facades in place; publish their state through `PhysicsBackend.sync_render_state()` without a physics step. 4. Attach parented cameras and publish the ready topology revision only after every stage succeeds. diff --git a/agent_context/topics/simulation-system/materials.md b/agent_context/topics/simulation-system/materials.md new file mode 100644 index 0000000000..33fc3fe58a --- /dev/null +++ b/agent_context/topics/simulation-system/materials.md @@ -0,0 +1,101 @@ +# Visual materials + +`embodichain/lab/sim/material.py` owns `VisualMaterialCfg`, material templates, +native instance wrappers and segment assignment helpers. Its private +`_material_desc_from_cfg()` validates and normalizes both runtime creation and +`spawn/descriptors.py` lowering. Handwritten configurations fill historical +defaults during construction. Preset configurations keep unspecified fields +as `None`; only explicit values override resources. `from_dict()` constructs in +that mode before filling defaults. Construction moves common parameter aliases +to flat fields so `replace(field=None)` can restore inheritance. Do not infer +override intent from equality +with a default, or modify generic `configclass` for this policy. + +Scattering is parameter-driven in both modes. `VisualMaterialCfg` has no +`material_type` field, and wrappers never force a native BRDF/BTDF/BSDF model. +Basic handwritten materials keep native descriptor creation without a lobe +override; advanced handwritten parameters use the manifest resolver and its +renderer preflight. Dictionary input rejects the removed field rather than +silently losing a legacy transmission choice. Migration examples live in the +material guide linked below. + +`_material_presets.py` owns validated schema-v1 overrides, renderer preflight and +World-scoped manifest/template caches. `SpawnScene._resolve_materials()` shares +that path with runtime creation. Its resource definition registry reserves UIDs +alongside native Spawn's simplified material registry; full resource definitions +must not pass through the simplified `MaterialDesc` initializer. Bind the exact +loaded child on `RenderDesc.material`, including across rebuilds. NoRender keeps +resource definitions without downloading or creating native materials. Render-only +facades stay declared and are skipped by runtime binding/reset in that mode. + +Built-in parameter presets resolve from wheel metadata without constructing the +downloadable resource dataset. Both sources use complete manifests and the same +override, identity and reset path. Human-facing previews and parameter tables +live in the [material guide](../../../docs/source/overview/sim/sim_manager/rendering/materials.md). +`embodichain/lab/scripts/preview_material.py` owns `preview-material`; the example +delegates to it with a headless default. Help/list use the lightweight catalog +without simulator imports. Rendering preflights the same configurations/backend +validator as production before World creation. Batch exclusions require explicit +`--compatible-only` and are reported; never switch renderers or silently drop +parameters. Geometry, studio settings and direct-native comparisons belong to +the preview, not the material factory. See the +[CLI guide](../../../docs/source/guides/preview_material.md) and +`tests/lab/scripts/test_preview_material.py`; keep capabilities in the catalog. + +Resource templates remain immutable. Each public UID and instance receives a +separate child; canonical revision/preset/overrides select shared templates. +Stage parameter variations before binding and keep texture paths in the shared +resource cache. Full resource reset uses native `Material.reset_inst()` instead +of rewriting only common fields. Native model changes after binding and NRD +resource fixes are outside this contract. Catalog/distribution ownership lives +in [data assets](../data-assets/data-assets.md); public usage and renderer limits +are documented in the +[material API](../../../docs/source/api_reference/embodichain/embodichain.lab.sim.material.rst). + +## Identity and state + +An unnamed configuration has no shared identity. Spawn derives its name from +the object and normalized appearance; the manager names standalone runtime +creations uniquely. Runtime and Spawn share the definition registry. Explicit +UIDs share only equivalent definitions; conflicting properties fail before +native writes. Equivalent aliases borrow current state without resetting it. +Definitions remain reserved for the World lifetime, including across native +unused-material cleanup. A template retains one wrapper per instance UID. Repeated +creation returns that wrapper without resetting properties; explicit +`set_default_properties()` applies a reset intentionally. + +Wrappers retain the exact native instance, including asset-parsed and reuse +instances. Every setter uses that handle. Scalar/color reads query native +state; DexSim's color getter supplies display-sRGB RGB, while configuration +and setters accept RGBA. Alpha cannot be verified through that getter. +Segment binding compares native material IDs rather than names. + +## Assignment lifetime + +Objects own their original per-segment assignments and selective reset +baselines. Before a topology rebuild, `SpawnScene` captures current assignments +of bound facades and replays them during binding. This preserves both deliberate +runtime replacements and completed restores even when retained native +descriptors still name an earlier replacement. A failed replay retains its +snapshot for preparation retry; successful replay and close release it. Removal +discards that object's pending entries before a same-UID replacement can bind. + +VisualObject additionally owns pose/visibility replay. Neither material replay +nor rendering advances physics. Native material handles and texture pools must +outlive their bindings and be released before their owning World. + +## Randomization boundary and validation + +[Visual randomization](../randomization/randomization.md) owns bounded native +texture pools, selected-environment sampling and tier choices. Imported asset +materials remain reset baselines; working instances are separate handles. +For repeated updates, prefer pre-created textures from the owning simulation +over per-call Tensor uploads. + +Primary CPU contracts are in `tests/sim/test_material.py`, +`tests/sim/spawn/test_scene.py` and +`tests/gym/envs/managers/test_randomize_visual_material.py`. Native parity, +selective mutation, stable instance capacity and topology replay live in +`tests/sim/test_material_integration.py`. The +[acceptance plan](../../../docs/architecture/visual-material-acceptance.md) +separates these checks from optical image and longer resource qualification. diff --git a/agent_context/topics/simulation-system/simulation-system.md b/agent_context/topics/simulation-system/simulation-system.md index a3ad31b6da..0dbd105423 100644 --- a/agent_context/topics/simulation-system/simulation-system.md +++ b/agent_context/topics/simulation-system/simulation-system.md @@ -10,6 +10,7 @@ | Manager-level backend hooks and capabilities | `embodichain/lab/sim/physics/` | | Object-level Scene batch adapters | `embodichain/lab/sim/objects/backends/scene.py`, `newton.py` | | Object, renderer and physics configuration | `embodichain/lab/sim/cfg/` (`cfg/__init__.py` is the public facade) | +| Visual material configuration, templates and native instances | `embodichain/lab/sim/material.py`; [material identity and lifetime](materials.md) | | Gym lifecycle and task scene construction | `embodichain/lab/gym/envs/base_env.py`, `embodied_env.py` | `embodichain.cli.sim` owns standalone simulation arguments without importing diff --git a/docs/architecture/visual-material-acceptance.md b/docs/architecture/visual-material-acceptance.md new file mode 100644 index 0000000000..e4e29e5988 --- /dev/null +++ b/docs/architecture/visual-material-acceptance.md @@ -0,0 +1,126 @@ +# Visual material acceptance + +Target `dexsim_engine==0.5.1rc1` with its matching Python wheel, Warp `1.17.0` +and Newton `1.6.0`. Install the repository with its bundled extensions before +running the checks. See [material contracts](../../agent_context/topics/simulation-system/materials.md) +for implementation ownership and lifecycle rules. + +## Acceptance criteria + +| Boundary | Required behavior | +| --- | --- | +| Identity and aliases | Unnamed materials keep independent appearances. Explicit UID conflicts fail across runtime and Spawn creation in either order. Equivalent aliases and repeated instance creation preserve native state. | +| PBR conversion | Both creation paths agree for parameter-driven opaque, reflective and transmitting materials, emission intensities 0/1/10 and nondefault scalars. No explicit native scattering type is written. Invalid values fail before native writes. RGBA writes retain alpha; RGB getters cannot verify it. | +| Native handles | Imported and working instances update their exact native handles. Material IDs distinguish different instances with identical names. | +| Selection and reset | Empty selections do nothing; invalid indices and partial shared selections fail before writes. Independent updates affect selected environments only. Deterministic rigid-object and group resets restore selected properties. | +| Randomization resources | Solid and library tiers honor scalar PBR ranges. Shared instances are mutated once per call. Different source directories remain separate; warmed texture pools and instance registries do not grow on repeated calls. | +| Rebuilds and replacement | Current per-segment assignments survive reset and topology rebuilds. Failed replay can retry; removal discards old snapshots before same-UID replacement. Equal replacement definitions reuse registration. | +| Native qualification | Hybrid scenes with two environments pass on Default and Newton. After 1,025 partial randomizations per backend, native instance counts remain stable, requested properties hold, and the other environment is unchanged. | + +Use fixed sampling ranges to test requested properties deterministically. +Use floating-point comparisons with an absolute tolerance of `1e-5` for native +color round trips. Solid palette colors include uint8 quantization and finite +palette sampling; they do not represent arbitrary continuous colors. + +## Commands + +Verify the actual runtime, then run CPU and native checks separately. Keep +native resource tests serial. + +```bash +python -c 'from importlib.metadata import version; import dexsim; assert version("dexsim-engine") == "0.5.1rc1"; print(version("warp-lang"), version("newton"))' + +python -m pytest -q -m 'not requires_sim and not gpu' \ + tests/sim/test_material.py tests/sim/test_material_texture_obj.py \ + tests/sim/test_material_cache_config.py \ + tests/sim/objects/test_asset_material_initialization.py \ + tests/sim/objects/test_rigid_object_reuse_material.py \ + tests/sim/objects/test_articulation_reuse_material.py \ + tests/gym/envs/managers/test_randomize_visual_material.py \ + tests/sim/objects/test_visual_object.py \ + tests/sim/spawn tests/sim/test_cfg.py tests/gym/utils/test_gym_utils.py + +python -m pytest -q --run-gpu \ + tests/sim/test_material_integration.py \ + tests/sim/objects/test_visual_object_integration.py::test_render_only_object_native_lifecycle + +python -m black --check . +python docs/scripts/check_api_docs.py +python .agents/skills/project-dev-context/scripts/context.py check +``` + +## Default material package + +In addition to the legacy handwritten-material checks above, qualify the unified +`VisualMaterialCfg.from_preset()` route: + +| Boundary | Acceptance | +| --- | --- | +| Inheritance | GlassClear inherits IOR 1.52; explicit 1.5 overrides it. Zero/false are explicit; `replace(ior=None)` restores inheritance. No flat/native duplicate properties or invalid parameters reach native loading. | +| Round trips | Constructor, factory, Python/YAML, copy, replace and dictionary round trips retain the preset and unspecified state. Manual construction retains historical concrete defaults. | +| Removed scattering selector | Python construction rejects `material_type`; dictionary/YAML loading reports its removal, including old null values. Serialization emits no selector. Explicit transmission parameters survive native loading, complete reset and topology rebuild. | +| Distribution | Offline queries list 24 public presets, including six built-ins without a texture-archive download, with SoapBubbleInterior available explicitly. Build/install a wheel away from the source checkout. With an empty data cache, download the pinned archive; repeat without network, repair a missing/corrupt member, and verify auxiliary texture references. | +| Renderer preflight | Ten basic presets load on Hybrid/FastRT/OfflineRT. Eight advanced presets, the helper and advanced parameter variations require OfflineRT. Reject before download/native loading. NoRender preserves declarations and does neither. | +| Native resources | Runtime-first and inline-first aliases share exact handles. Different UIDs use separate children of immutable templates. Repeated creation preserves state and bounded native instance counts. Variants stage JSON only, never duplicate PNGs. | +| Lifecycle | Complete native template reset restores properties beyond the flattened fields. Default/Newton selection, reset, topology rebuild and cleanup preserve identity and expected assignments. | +| Optical parity | Render all 24 on OfflineRT and the fourteen compatible presets on Hybrid and FastRT. Compare the package route to direct native loading with identical geometry/camera/lights, plus a native-repeat noise control. Use closed glass meshes, explicit double shells for bubbles and optional segmented face geometry. | +| Preview CLI | Installed-wheel help/list need no simulator imports, GPU or asset download. Overrides preserve inheritance and explicit zero/false. Incompatible requests fail before World creation; compatible-only batches report every exclusion. Captures and metadata reproduce the selected configuration. | + +```bash +python -m pytest -q tests/data/test_default_materials.py tests/sim/test_material.py tests/lab/scripts/test_preview_material.py tests/test_main.py +python -m pytest -q --run-gpu tests/sim/test_material_integration.py + +# Each renderer requires a separate process. Run native checks serially. +embodichain preview-material --all --headless --compare-native --renderer rt +embodichain preview-material --all --compatible-only --compare-native --renderer hybrid +embodichain preview-material --all --compatible-only --compare-native --renderer fast-rt +# Optionally add --model /path/to/skin_face.glb for SkinHumanLight. +``` + +The OfflineRT overview below was generated from the packaged preset previews; +the skin sample uses the optional segmented face mesh. + +![Default material OfflineRT previews](../source/_static/visual_materials/default-materials.webp) + +The preview writes candidate/native/repeat images and `results.json`. Its center +crop RMSE tolerance is max(3/255, three times native-repeat RMSE plus 0.5/255). +Inspect the images as well as the metric: similar dark or empty frames alone do +not establish material correctness. The first material load includes data-cache +verification; creation timings are descriptive, not a render-throughput claim. +The current pinned upstream address is internal; public mirrors must provide +exactly the archive and versioned path documented in the material API. Asset +redistribution/provenance must be retained when deploying a mirror. + +Parameter variants must be created before binding. Qualification does not cover +cross-model hot shader mutation, NRD packaging fixes or automatic geometry/UV +changes. The public `material_type` selector is removed; legacy configurations +must explicitly describe their intended optical properties. + +## Compatibility + +- `base_color` reads return native display-sRGB RGB; configuration and writes + still accept RGBA. +- Unnamed configurations use `uid=None`. Remove an old explicit + `uid: default_mat` or assign distinct UIDs when independent appearances are required. +- Repeated `create_instance(uid)` preserves identity and state. Use + `set_default_properties()` for an explicit property reset. +- Explicit definitions remain reserved for the World lifetime, including after + `clean_materials()` releases runtime wrappers and unused native resources. +- Shared material randomization requires all environments; partial selections + are rejected. + +## Release qualification + +State and lifecycle checks do not establish optical correctness. Before release: + +- Compare opaque/metal/mirror/transmitting parameter sets, alpha and emission intensities + 0/1/10 in a separate FastRT process with controlled lighting, camera and exposure. +- Check multi-material meshes, URDF articulations and deformables across partial + reset and rebuilds. Compare file, Tensor and native Texture inputs for color + space, normal-map direction and rebinding behavior. +- Run 10,000 resets while recording native instance counts, texture counts and + VRAM. Distinguish warmup caches from sustained growth; Torch allocator statistics + do not cover all Vulkan/OptiX memory. + +Record run-specific environment details, timings, results and unexecuted checks +in the PR description. diff --git a/docs/source/_static/visual_materials/AsphaltDark.webp b/docs/source/_static/visual_materials/AsphaltDark.webp new file mode 100644 index 0000000000..57560f5b42 Binary files /dev/null and b/docs/source/_static/visual_materials/AsphaltDark.webp differ diff --git a/docs/source/_static/visual_materials/BrickRed.webp b/docs/source/_static/visual_materials/BrickRed.webp new file mode 100644 index 0000000000..7fad37d7c7 Binary files /dev/null and b/docs/source/_static/visual_materials/BrickRed.webp differ diff --git a/docs/source/_static/visual_materials/CeramicTileWhite.webp b/docs/source/_static/visual_materials/CeramicTileWhite.webp new file mode 100644 index 0000000000..e97392bf0f Binary files /dev/null and b/docs/source/_static/visual_materials/CeramicTileWhite.webp differ diff --git a/docs/source/_static/visual_materials/ChromePolished.webp b/docs/source/_static/visual_materials/ChromePolished.webp new file mode 100644 index 0000000000..84760dde94 Binary files /dev/null and b/docs/source/_static/visual_materials/ChromePolished.webp differ diff --git a/docs/source/_static/visual_materials/CopperPolished.webp b/docs/source/_static/visual_materials/CopperPolished.webp new file mode 100644 index 0000000000..83e35acffb Binary files /dev/null and b/docs/source/_static/visual_materials/CopperPolished.webp differ diff --git a/docs/source/_static/visual_materials/FabricGray.webp b/docs/source/_static/visual_materials/FabricGray.webp new file mode 100644 index 0000000000..b96a17a86c Binary files /dev/null and b/docs/source/_static/visual_materials/FabricGray.webp differ diff --git a/docs/source/_static/visual_materials/GlassClear.webp b/docs/source/_static/visual_materials/GlassClear.webp new file mode 100644 index 0000000000..2e0102c837 Binary files /dev/null and b/docs/source/_static/visual_materials/GlassClear.webp differ diff --git a/docs/source/_static/visual_materials/GlassClearSurface.webp b/docs/source/_static/visual_materials/GlassClearSurface.webp new file mode 100644 index 0000000000..f556e09a4a Binary files /dev/null and b/docs/source/_static/visual_materials/GlassClearSurface.webp differ diff --git a/docs/source/_static/visual_materials/GlassFrosted.webp b/docs/source/_static/visual_materials/GlassFrosted.webp new file mode 100644 index 0000000000..26837a941c Binary files /dev/null and b/docs/source/_static/visual_materials/GlassFrosted.webp differ diff --git a/docs/source/_static/visual_materials/GlassFrostedSurface.webp b/docs/source/_static/visual_materials/GlassFrostedSurface.webp new file mode 100644 index 0000000000..4f3ffab331 Binary files /dev/null and b/docs/source/_static/visual_materials/GlassFrostedSurface.webp differ diff --git a/docs/source/_static/visual_materials/GlassSmoke.webp b/docs/source/_static/visual_materials/GlassSmoke.webp new file mode 100644 index 0000000000..ad06be71ad Binary files /dev/null and b/docs/source/_static/visual_materials/GlassSmoke.webp differ diff --git a/docs/source/_static/visual_materials/GoldMetal.webp b/docs/source/_static/visual_materials/GoldMetal.webp new file mode 100644 index 0000000000..88e4ff184d Binary files /dev/null and b/docs/source/_static/visual_materials/GoldMetal.webp differ diff --git a/docs/source/_static/visual_materials/GoldPolished.webp b/docs/source/_static/visual_materials/GoldPolished.webp new file mode 100644 index 0000000000..27e78194d9 Binary files /dev/null and b/docs/source/_static/visual_materials/GoldPolished.webp differ diff --git a/docs/source/_static/visual_materials/LeatherBrown.webp b/docs/source/_static/visual_materials/LeatherBrown.webp new file mode 100644 index 0000000000..d9c1465e0f Binary files /dev/null and b/docs/source/_static/visual_materials/LeatherBrown.webp differ diff --git a/docs/source/_static/visual_materials/MarbleWhite.webp b/docs/source/_static/visual_materials/MarbleWhite.webp new file mode 100644 index 0000000000..1b89e7a4fd Binary files /dev/null and b/docs/source/_static/visual_materials/MarbleWhite.webp differ diff --git a/docs/source/_static/visual_materials/MirrorMetal.webp b/docs/source/_static/visual_materials/MirrorMetal.webp new file mode 100644 index 0000000000..efa5465fca Binary files /dev/null and b/docs/source/_static/visual_materials/MirrorMetal.webp differ diff --git a/docs/source/_static/visual_materials/PlasticABSBlack.webp b/docs/source/_static/visual_materials/PlasticABSBlack.webp new file mode 100644 index 0000000000..38b2270f8c Binary files /dev/null and b/docs/source/_static/visual_materials/PlasticABSBlack.webp differ diff --git a/docs/source/_static/visual_materials/PlasticGlossyRed.webp b/docs/source/_static/visual_materials/PlasticGlossyRed.webp new file mode 100644 index 0000000000..9ac0e6d4d9 Binary files /dev/null and b/docs/source/_static/visual_materials/PlasticGlossyRed.webp differ diff --git a/docs/source/_static/visual_materials/PlasticMatteBlue.webp b/docs/source/_static/visual_materials/PlasticMatteBlue.webp new file mode 100644 index 0000000000..799a087dc5 Binary files /dev/null and b/docs/source/_static/visual_materials/PlasticMatteBlue.webp differ diff --git a/docs/source/_static/visual_materials/RubberBlack.webp b/docs/source/_static/visual_materials/RubberBlack.webp new file mode 100644 index 0000000000..5a509c8914 Binary files /dev/null and b/docs/source/_static/visual_materials/RubberBlack.webp differ diff --git a/docs/source/_static/visual_materials/SkinHumanLight.webp b/docs/source/_static/visual_materials/SkinHumanLight.webp new file mode 100644 index 0000000000..da68c5986a Binary files /dev/null and b/docs/source/_static/visual_materials/SkinHumanLight.webp differ diff --git a/docs/source/_static/visual_materials/SoapBubble.webp b/docs/source/_static/visual_materials/SoapBubble.webp new file mode 100644 index 0000000000..4043c7c401 Binary files /dev/null and b/docs/source/_static/visual_materials/SoapBubble.webp differ diff --git a/docs/source/_static/visual_materials/SteelBrushed.webp b/docs/source/_static/visual_materials/SteelBrushed.webp new file mode 100644 index 0000000000..2fbdf3bff0 Binary files /dev/null and b/docs/source/_static/visual_materials/SteelBrushed.webp differ diff --git a/docs/source/_static/visual_materials/WoodOak.webp b/docs/source/_static/visual_materials/WoodOak.webp new file mode 100644 index 0000000000..60f109022a Binary files /dev/null and b/docs/source/_static/visual_materials/WoodOak.webp differ diff --git a/docs/source/_static/visual_materials/advanced-materials.webp b/docs/source/_static/visual_materials/advanced-materials.webp new file mode 100644 index 0000000000..067adfddb5 Binary files /dev/null and b/docs/source/_static/visual_materials/advanced-materials.webp differ diff --git a/docs/source/_static/visual_materials/builtin-materials.webp b/docs/source/_static/visual_materials/builtin-materials.webp new file mode 100644 index 0000000000..11eb700744 Binary files /dev/null and b/docs/source/_static/visual_materials/builtin-materials.webp differ diff --git a/docs/source/_static/visual_materials/default-materials.webp b/docs/source/_static/visual_materials/default-materials.webp new file mode 100644 index 0000000000..4890b7a10a Binary files /dev/null and b/docs/source/_static/visual_materials/default-materials.webp differ diff --git a/docs/source/_static/visual_materials/surface-materials.webp b/docs/source/_static/visual_materials/surface-materials.webp new file mode 100644 index 0000000000..d3e83e73a3 Binary files /dev/null and b/docs/source/_static/visual_materials/surface-materials.webp differ diff --git a/docs/source/api_reference/embodichain/embodichain.lab.sim.material.rst b/docs/source/api_reference/embodichain/embodichain.lab.sim.material.rst index 0d97feb637..c50cd44bee 100644 --- a/docs/source/api_reference/embodichain/embodichain.lab.sim.material.rst +++ b/docs/source/api_reference/embodichain/embodichain.lab.sim.material.rst @@ -14,6 +14,116 @@ passes; :class:`VisualMaterial` is the resolved material definition and :class:`ReuseSegmentState` tracks per-render-body segment reuse so that segmentation masks stay consistent across replicated environments. +Default material presets +------------------------ + +The 24-preset catalog contains six built-in parameter definitions and 18 +downloadable resources. See the +:doc:`material guide ` for +parameter tables, individual previews and OfflineRT galleries. + +Handwritten and asset-based materials use the same configuration type:: + + from embodichain.data import list_default_materials, get_default_material_info + from embodichain.lab.sim import VisualMaterialCfg + from embodichain.lab.sim.shapes import SphereCfg + + cfg = VisualMaterialCfg.from_preset( + "GlassClear", uid="glass", ior=1.6, + parameters={"volume_attenuation_distance": 8.0}, + ) + material = sim.create_visual_material(cfg) + shape = SphereCfg(radius=0.25, visual_material=cfg) + +The equivalent YAML is: + +.. code-block:: yaml + + visual_material: + preset: GlassClear + uid: glass + ior: 1.6 + parameters: + volume_attenuation_distance: 8.0 + +With a preset, unspecified common fields remain ``None`` and inherit authored +values. GlassClear therefore retains IOR 1.52 unless an override is supplied; +explicit ``ior=1.5`` sets 1.5. Zero and false values are explicit overrides. +``cfg.replace(ior=None)`` restores inheritance. ``copy()``, ``replace()`` and +dictionary/YAML round trips preserve the other explicit fields. Construct a +new source with ``from_preset()``; ``replace()`` retains the original fields. +Handwritten configurations still fill the historical defaults at construction. + +Common fields stay flat; native schema-v1 advanced parameters belong in +``parameters``. ``emissive_intensity`` normalizes to ``emission_intensity``. +Common properties supplied through ``parameters`` are moved to their flat +fields during construction, so ``replace(ior=None)`` also clears that form of +override. The remaining advanced properties stay in ``parameters``. +Specifying the same native property in both places is an error. Unknown, +nonfinite, incorrectly typed and out-of-range parameters fail before native +loading. Presets reject texture replacement. Both handwritten and preset +materials select scattering from optical parameters; ``material_type`` and +the ``VisualMaterial.RT_MATERIAL_TYPES`` / ``MAT_TYPE_MAPPING`` constants have +been removed. No default BRDF is explicitly written to native instances. +Manual configurations with advanced parameters use a complete native manifest:: + + ice = VisualMaterialCfg( + base_color=[1, 1, 1, 1], roughness=0.2, ior=1.31, + parameters={"transmission": 1.0}, + ) + +Transmission and other advanced handwritten parameters require OfflineRT. +Remove ``material_type`` from stored Python/YAML configurations; ``from_dict`` +rejects the removed key even if its value is ``None``. Legacy BRDF/BTDF/BSDF +labels are not silently converted because their scattering semantics are not +interchangeable. See the +:doc:`migration example `. + +``BrickRed``, ``WoodOak``, ``CeramicTileWhite``, ``MarbleWhite``, ``AsphaltDark``, +``FabricGray``, ``LeatherBrown``, ``PlasticABSBlack``, ``RubberBlack`` and +``SteelBrushed`` support ``hybrid``, ``fast-rt`` and ``rt``. ``ChromePolished``, +``GoldPolished``, ``CopperPolished``, ``GlassClear``, ``GlassFrosted``, +``GlassSmoke``, ``SkinHumanLight`` and ``SoapBubble`` require ``rt`` (OfflineRT). +The built-ins ``PlasticMatteBlue``, ``PlasticGlossyRed``, ``GoldMetal`` and +``MirrorMetal`` support all three renderers. ``GlassClearSurface`` and +``GlassFrostedSurface`` require ``rt`` and configure surface transmission without +volume absorption. +Advanced parameter variations of a basic preset also require ``rt``; the +factory rejects incompatible renderers before downloading or loading assets. +NoRender stores declarations without downloading or creating native materials. +Render-only VisualObject facades remain unbound in that mode; their declarations +are retained, while physics assets still prepare normally. + +Variants stage only JSON before binding. Textures stay in the shared resource +cache. Equivalent definitions reuse immutable native templates, while each UID +has a separate child instance. Explicit resets restore the entire template, +including advanced parameters and textures. Repeated creation preserves state. +Native shader-model changes after binding are outside this interface's scope; +pre-create variants and bind their instances instead. + +Catalog queries do not require a World or asset download. The six built-ins +include native parameter defaults in the wheel and do not request the texture +archive. Their metadata exposes ``source="builtin"`` and +``requires_download=False``. Resource metadata uses ``source="resource"``. +The archive is lazy, +revisioned and checksum-verified. Pre-download with +``embodichain data download --name DefaultMaterials``. The current upstream +archive is hosted on the DexSim internal resource server. To use a mirror, set +``EMBODICHAIN_DOWNLOAD_PREFIX`` before importing EmbodiChain and place the same +archive at +``materials/default-materials/ec9f748242c50b85/offlinert_materials.zip`` below +that prefix. The wheel contains metadata, not PNGs. The downloaded archive +retains the upstream README and manifests; source attribution and file digests +are recorded in ``embodichain/data/default_materials.json``. + +The optional ``SoapBubbleInterior`` auxiliary material references SoapBubble's +textures. Hollow bubble shells and segmented skin meshes belong to preview +geometry. The factory neither creates those shapes nor changes UVs. +``SkinHumanLight`` approximates diffuse transmission rather than random-walk +subsurface scattering. Use ``embodichain preview-material`` for previews, +optional segmented-face geometry and direct-native image comparisons; see the +:doc:`CLI guide `. + .. rubric:: Classes .. autosummary:: diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index ea1f17cc6e..095a621c3d 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -40,6 +40,26 @@ EmbodiChain data cache. ScoopIceNewEnv TabletopFourBases +embodichain.data.assets.materials +------------------------------------ + +Material, environment-map and background bundles use the shared asset cache. +``DefaultMaterials`` downloads the pinned 18-preset collection and its auxiliary +soap-film layer; it does not create a simulator World. + +.. currentmodule:: embodichain.data.assets.materials + +.. autosummary:: + + SimResources + EnvMapHDR + CocoBackground + BackgroundTexture + DefaultMaterials + +.. autoclass:: DefaultMaterials + :members: get_material_path + embodichain.data.assets.planner_assets -------------------------------------- @@ -118,6 +138,36 @@ corrected full-body definition. .. autoclass:: DexforceW1RightArmV021 +embodichain.data.default_materials +------------------------------------ + +The wheel includes catalog metadata so these functions work offline without +asset downloads. Both functions are also available from ``embodichain.data``. The 24-preset +catalog combines six built-in parameter definitions and 18 downloadable +resources; metadata identifies their source and download requirement. +Metadata includes compatible renderer names, source revision, dependencies, +auxiliary resources and suggested physical texture dimensions. Dimensions do +not change geometry or UVs. + +.. currentmodule:: embodichain.data.default_materials + +.. autosummary:: + + list_default_materials + get_default_material_info + +.. autofunction:: list_default_materials + +.. autofunction:: get_default_material_info + +.. currentmodule:: embodichain.data + +.. autofunction:: list_default_materials + :no-index: + +.. autofunction:: get_default_material_info + :no-index: + embodichain.data_pipeline ------------------------- @@ -869,6 +919,24 @@ embodichain.lab.scripts.preview_lerobot_data main resolve_dataset_root +embodichain.lab.scripts.preview_material +---------------------------------------- + +.. currentmodule:: embodichain.lab.scripts.preview_material + +Query the default catalog without loading simulation dependencies, or render +presets and parameter variants with fixed preview settings and managed cleanup. + +.. autosummary:: + + main + cli + +.. autofunction:: main + +.. autofunction:: cli + + embodichain.lab.scripts.preview_robot ------------------------------------- diff --git a/docs/source/guides/cli.md b/docs/source/guides/cli.md index f9f8b131f4..c210501d82 100644 --- a/docs/source/guides/cli.md +++ b/docs/source/guides/cli.md @@ -91,6 +91,24 @@ See {doc}`preview_robot` for robot files, overrides, and bounded headless runs. --- +(cli-preview-material)= +## Preview Material + +Query default presets, open a native material preview or export rendered images: + +```bash +embodichain preview-material --list +embodichain preview-material --material GlassClear +embodichain preview-material --all --headless --gallery --output outputs/materials +``` + +OfflineRT (`--renderer rt`) is the default. Other renderers reject incompatible +requests; `--all --compatible-only` explicitly selects and reports supported +presets. See {doc}`preview_material` for JSON overrides, output metadata, +direct-native comparisons and renderer requirements. + +--- + (cli-list-tasks)= ## List Tasks diff --git a/docs/source/guides/index.rst b/docs/source/guides/index.rst index ba6a9518cf..fe4daf74d5 100644 --- a/docs/source/guides/index.rst +++ b/docs/source/guides/index.rst @@ -12,6 +12,7 @@ Practical guides for common tasks in EmbodiChain. add_robot preview_asset preview_robot + preview_material run_task expert_task_language offline_expert_datasets diff --git a/docs/source/guides/preview_material.md b/docs/source/guides/preview_material.md new file mode 100644 index 0000000000..abee9c059d --- /dev/null +++ b/docs/source/guides/preview_material.md @@ -0,0 +1,118 @@ +# Preview default materials + +`embodichain preview-material` previews a default material in a native DexSim +window or exports images from a fixed studio scene. It uses the same +`VisualMaterialCfg.from_preset()` resolver as runtime and Spawn material creation. +`python -m embodichain preview-material` is equivalent and works outside the +source checkout after installing the wheel. + +See the {doc}`material catalog ` +for all 24 public presets, their parameters, OfflineRT images and download setup. + +## Query and preview + +```bash +# Metadata only: no simulator imports, GPU initialization or material download. +embodichain preview-material --list +embodichain preview-material --list --source builtin + +# Open the native window after saving a reference capture. +embodichain preview-material --material GlassClear + +# Export one image and exit without a window. +embodichain preview-material --material PlasticMatteBlue --headless \ + --output outputs/blue +``` + +The default material is `GlassClear` and the default renderer is `rt` +(**OfflineRT**), which supports the complete catalog. A single preview stays +open until Ctrl+C; `--max-steps 100` bounds its preview updates. Physics is +disabled. Use `--gpu-id` to select the render GPU; `--size`, `--frames` and +`--spp` control capture resolution, accumulation frames and DexSim render +iterations per frame. The defaults are 320 pixels, four frames and 16 iterations. + +## Override a preset + +Pass a JSON object containing `VisualMaterialCfg` fields. Common fields stay +flat, while advanced fields belong inside `parameters`: + +```bash +embodichain preview-material --material GlassClear --headless \ + --material-params '{"ior":1.6,"parameters":{"volume_attenuation_distance":8.0}}' \ + --output outputs/glass-variant + +embodichain preview-material --material PlasticMatteBlue --headless \ + --material-params '{"roughness":0}' --output outputs/smooth-blue +``` + +Omitted fields and JSON `null` inherit the preset. Explicit `0` and `false` +remain overrides. Duplicate flat/native properties, invalid values, +the removed `material_type` field and texture edits fail before creating a World +or loading assets. Both handwritten and preset configurations now select +scattering from optical parameters; see the catalog's migration example. +The command owns material UIDs; select the preset using `--material`. + +Variants are created before binding. The preview does not depend on native +cross-model hot mutation or change the material factory's geometry/UV behavior. + +## Batch export and renderer requirements + +```bash +# All 24 presets, individual WebPs and grouped galleries. Batch mode has no window. +embodichain preview-material --all --headless --gallery \ + --size 640 --frames 6 --spp 12 --output outputs/material-catalog + +# Only the six built-in parameter definitions; no material-archive download. +embodichain preview-material --all --source builtin --gallery + +# Explicitly export the 14 presets supported by Hybrid. +embodichain preview-material --all --renderer hybrid --compatible-only \ + --output outputs/materials-hybrid +``` + +Hybrid and FastRT support ten basic resource presets and four built-in +parameter presets. The remaining ten public presets and advanced parameter +variations require OfflineRT. An incompatible request fails before resource +download or native loading; the command never changes the renderer or downgrades +material parameters. With `--all --compatible-only`, excluded names and reasons +are printed to stderr and recorded in `results.json`. If no presets remain, +the command fails. `--source` filters `--list` and `--all`; `--material-params` +applies the supplied overrides to every selected preset. + +Each renderer must run in a separate process. `--gallery` requires Pillow and +matplotlib; install those packages if they are absent. Gallery composition runs +after native renderer cleanup and uses the saved captures. + +## Outputs and direct-native verification + +Every render writes `_preset.png` and `results.json` under +`--output` (default `outputs/default-materials`). Use a separate directory per +variant or renderer to retain previous results. The report records the scene +recipe, renderer and DexSim version, capture settings, material source/revision, +serialized configuration, geometry choice, parameter readback and exclusions. +Unspecified configuration fields stay `null`; authored resource defaults are +not expanded into explicit overrides. + +`--gallery` additionally writes `.webp`, `default-materials.webp` +and nonempty built-in, basic-resource and advanced-resource galleries. + +```bash +# Compare the preset resolver with the original native definitions. +embodichain preview-material --all --headless --compare-native \ + --renderer rt --output outputs/material-parity +``` + +`--compare-native` also saves `*_native.png` and `*_native_repeat.png`, checks +parameter readback and compares the center crop with a repeat-render noise +control. A mismatch fails the command. This mode verifies authored presets and +cannot be combined with `--material-params`. + +The scene uses fixed colored cards, a checkerboard, rectangular lights and +exposure. Architecture/wood/stone/ground presets use a slab; other presets use +a sphere. `SoapBubble` has an explicit inward-facing inner shell. An optional +`--model /path/to/skin_face.glb` supplies a segmented face for `SkinHumanLight` +or a batch containing it; that model is not downloaded with the materials. + +The existing `examples/sim/rendering/default_materials.py` delegates to this +command and retains its headless default. Use the production CLI for window +preview or add `--no-headless` to the example. diff --git a/docs/source/overview/sim/sim_manager/rendering/index.rst b/docs/source/overview/sim/sim_manager/rendering/index.rst index 38d9eb42f1..9a4d39db0d 100644 --- a/docs/source/overview/sim/sim_manager/rendering/index.rst +++ b/docs/source/overview/sim/sim_manager/rendering/index.rst @@ -8,6 +8,7 @@ quality, DLSS, and related performance trade-offs. Start with :doc:`configuration` for common ``RenderCfg`` options. Use :doc:`dlss` for NVIDIA DLSS behavior and temporal-rendering settings. +Use :doc:`materials` for default material presets, parameters and OfflineRT galleries. See also -------- @@ -17,3 +18,4 @@ See also configuration.md dlss.md + materials.md diff --git a/docs/source/overview/sim/sim_manager/rendering/materials.md b/docs/source/overview/sim/sim_manager/rendering/materials.md new file mode 100644 index 0000000000..cfd34ae668 --- /dev/null +++ b/docs/source/overview/sim/sim_manager/rendering/materials.md @@ -0,0 +1,325 @@ +# Visual materials and default presets + +EmbodiChain provides **24 public material presets** through one +`VisualMaterialCfg.from_preset()` interface: six built-in parameter presets and +18 downloadable resource presets. A separate `SoapBubbleInterior` auxiliary +material supplies the inward-facing interface for the bubble preview. + +The images below are actual DexSim **0.5.1rc1 OfflineRT** renders. All presets +use the same studio lighting and exposure; a checkerboard and colored cards +make reflection, transmission and roughness visible. Click a material name in +a table to open its individual image. + +## Create and override a material + +Select `RenderCfg(renderer="rt")` for OfflineRT; see {doc}`configuration` for +renderer and denoising options. With an existing simulation manager `sim`: + +```python +from embodichain.lab.sim import VisualMaterialCfg +from embodichain.lab.sim.shapes import SphereCfg + +cfg = VisualMaterialCfg.from_preset("PlasticMatteBlue", uid="blue") +material = sim.create_visual_material(cfg) +shape = SphereCfg(radius=0.25, visual_material=cfg) + +# Create another variant before binding it to geometry. +glossy_cfg = cfg.replace(uid="blue_glossy", roughness=0.08) +glossy_material = sim.create_visual_material(glossy_cfg) +``` + +Unspecified fields remain `None` and inherit their preset values. Explicit zero +and false values override the preset. `cfg.replace(roughness=None)` restores +inheritance. Common fields stay flat; advanced native parameters belong in +`parameters`: + +```python +glass = VisualMaterialCfg.from_preset( + "GlassClear", ior=1.6, + parameters={"volume_attenuation_distance": 8.0}, +) +``` + +Both handwritten materials and presets select scattering through native optical +parameters. Keep variants pre-created and rebind their instances when changing +appearance. `VisualMaterialCfg` no longer exposes `material_type`. +See the {doc}`material API ` +for UID sharing, validation, copy/replace, reset and lifecycle rules. + +### Migrate handwritten materials + +Opaque materials keep their usual base color, metallic, roughness, emission and +IOR defaults. For transmission, specify the optical properties explicitly: + +```python +ice = VisualMaterialCfg( + base_color=[1.0, 1.0, 1.0, 1.0], + roughness=0.2, + ior=1.31, + parameters={"transmission": 1.0}, +) +``` + +This transmitting configuration requires OfflineRT (`renderer="rt"`). Native +parameters select the reflection/transmission model before geometry is bound. +The factory no longer forces a BRDF model on handwritten defaults. + +Remove `material_type` from Python and YAML configurations. Dictionary loading +rejects the removed key, including a serialized `null`, with a migration error. +Old BRDF/BTDF/BSDF labels are not automatically converted: pure transmission and +reflection-plus-transmission have different semantics. Select a glass preset +or specify transmission, IOR, roughness and any required volume/thin-wall +properties for the intended material. `RT_MATERIAL_TYPES` and +`MAT_TYPE_MAPPING` have also been removed from `VisualMaterial`. + +## Built-in parameter presets + +These six definitions are included in the wheel and **do not download the +`DefaultMaterials` texture archive**. They contain no texture maps. The simulator +initialization and its common scene resources follow their usual setup. + +![Built-in parameter presets rendered with OfflineRT](../../../../_static/visual_materials/builtin-materials.webp) + +| Preset | Base color RGBA | Metallic | Roughness | Transmission | IOR | Renderers | +| --- | --- | ---: | ---: | ---: | ---: | --- | +| [PlasticMatteBlue](../../../../_static/visual_materials/PlasticMatteBlue.webp) | `[0.05, 0.22, 0.6, 1]` | 0 | 0.65 | 0 | 1.5 | Hybrid / FastRT / OfflineRT | +| [PlasticGlossyRed](../../../../_static/visual_materials/PlasticGlossyRed.webp) | `[0.55, 0.025, 0.025, 1]` | 0 | 0.08 | 0 | 1.5 | Hybrid / FastRT / OfflineRT | +| [GoldMetal](../../../../_static/visual_materials/GoldMetal.webp) | `[0.95, 0.76, 0.32, 1]` | 1 | 0.22 | 0 | 1.5 | Hybrid / FastRT / OfflineRT | +| [MirrorMetal](../../../../_static/visual_materials/MirrorMetal.webp) | `[0.95, 0.95, 0.95, 1]` | 1 | 0 | 0 | 1.5 | Hybrid / FastRT / OfflineRT | +| [GlassClearSurface](../../../../_static/visual_materials/GlassClearSurface.webp) | `[1, 1, 1, 1]` | 0 | 0 | 1 | 1.5 | OfflineRT | +| [GlassFrostedSurface](../../../../_static/visual_materials/GlassFrostedSurface.webp) | `[1, 1, 1, 1]` | 0 | 0.28 | 1 | 1.5 | OfflineRT | + +All six use `emissive=[0, 0, 0]` and `emission_intensity=1`. The two surface-glass +presets explicitly use `thin_walled=true` and configure no volume absorption. +Their supported factory backend is OfflineRT. + +Choose `GlassClearSurface` or `GlassFrostedSurface` for simple surface transport. +Choose the resource `GlassClear`, `GlassFrosted` or `GlassSmoke` when a closed +object needs authored volume absorption and texture detail. These are separate +material definitions with different optical parameters. + +## Resource presets + +The resource collection keeps its native texture maps and material manifests. +It is downloaded lazily through `DefaultMaterials`, with a revision-specific +cache, archive/member integrity checks and the existing download lock. + +### Basic texture materials + +![Basic resource materials rendered with OfflineRT](../../../../_static/visual_materials/surface-materials.webp) + +All ten support **Hybrid, FastRT and OfflineRT**. Each uses 1024×1024 color, +roughness, metallic, ambient-occlusion and OpenGL normal maps. Their legacy +directories declare no scalar material manifest, so those fields retain the +native DexSim defaults. The factory does not substitute handwritten-config +defaults. Suggested dimensions describe a texture repeat; they do not resize +geometry or change UVs. + +| Preset | Appearance | Suggested texture dimensions, m | +| --- | --- | --- | +| [BrickRed](../../../../_static/visual_materials/BrickRed.webp) | Red fired brick with gray mortar | 1.2 × 0.84 | +| [WoodOak](../../../../_static/visual_materials/WoodOak.webp) | Warm oak wood grain | 0.8 × 0.8 | +| [CeramicTileWhite](../../../../_static/visual_materials/CeramicTileWhite.webp) | White glazed ceramic tiles with gray grout | 1 × 1 | +| [MarbleWhite](../../../../_static/visual_materials/MarbleWhite.webp) | Polished white marble with gray veins | 1.5 × 1.5 | +| [AsphaltDark](../../../../_static/visual_materials/AsphaltDark.webp) | Dark asphalt with fine aggregate | 2 × 2 | +| [FabricGray](../../../../_static/visual_materials/FabricGray.webp) | Fine woven gray fabric | 0.25 × 0.25 | +| [LeatherBrown](../../../../_static/visual_materials/LeatherBrown.webp) | Brown grained leather | 0.35 × 0.35 | +| [PlasticABSBlack](../../../../_static/visual_materials/PlasticABSBlack.webp) | Black ABS plastic with a subtle orange-peel finish | 0.25 × 0.25 | +| [RubberBlack](../../../../_static/visual_materials/RubberBlack.webp) | Rough black rubber | 0.25 × 0.25 | +| [SteelBrushed](../../../../_static/visual_materials/SteelBrushed.webp) | Directional brushed steel | 0.5 × 0.5 | + +### Advanced resource materials + +![Advanced resource materials rendered with OfflineRT](../../../../_static/visual_materials/advanced-materials.webp) + +These eight presets require **OfflineRT**. Incompatible renderers fail before +asset download or native material loading. Advanced parameter variations of +basic presets also require OfflineRT. + +| Preset | Appearance | Important authored parameters | +| --- | --- | --- | +| [ChromePolished](../../../../_static/visual_materials/ChromePolished.webp) | Highly reflective polished chrome | metallic 1; anisotropy 0.16 | +| [GoldPolished](../../../../_static/visual_materials/GoldPolished.webp) | Polished gold | metallic 1; anisotropy 0.10 | +| [CopperPolished](../../../../_static/visual_materials/CopperPolished.webp) | Polished copper | metallic 1; anisotropy 0.12 | +| [GlassClear](../../../../_static/visual_materials/GlassClear.webp) | Clear solid glass with weak volume absorption | IOR 1.52; transmission 1; attenuation distance 15 | +| [GlassFrosted](../../../../_static/visual_materials/GlassFrosted.webp) | Rough transmitting frosted glass | IOR 1.50; transmission 0.94; attenuation distance 2.5 | +| [GlassSmoke](../../../../_static/visual_materials/GlassSmoke.webp) | Gray-blue absorbing smoke glass | IOR 1.52; transmission 0.98; attenuation distance 0.45 | +| [SkinHumanLight](../../../../_static/visual_materials/SkinHumanLight.webp) | Light human skin with pores and approximate diffuse transmission | IOR 1.4; specular 0.48; diffuse transmission 0.16 | +| [SoapBubble](../../../../_static/visual_materials/SoapBubble.webp) | Soap film with spatially varying iridescence | IOR 1.33; film thickness 250–520 nm; iridescence factor 1 | + +With texture maps, `base_color`, `metallic` and `roughness` are factors. A +`roughness` factor of 1 preserves the roughness map distribution. Gold and +copper coloration comes from their color textures. + +### Full advanced parameter definitions + +The following are the resource manifests' explicit native parameters. Unlisted +fields inherit native defaults. The native `emission_intensity` key corresponds +with the flat configuration field `emissive_intensity`. + +```yaml +ChromePolished: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 1.0 + roughness: 1.0 + specular_strength: 1.0 + anisotropy_strength: 0.16 + anisotropy_rotation: 0.0 + double_sided: false +GoldPolished: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 1.0 + roughness: 1.0 + specular_strength: 1.0 + anisotropy_strength: 0.1 + anisotropy_rotation: 0.0 + double_sided: false +CopperPolished: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 1.0 + roughness: 1.0 + specular_strength: 1.0 + anisotropy_strength: 0.12 + anisotropy_rotation: 0.0 + double_sided: false +GlassClear: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 0.0 + roughness: 1.0 + ior: 1.52 + transmission: 1.0 + specular_strength: 1.0 + volume_thickness: 1.0 + volume_attenuation_color: [0.995, 0.998, 1.0] + volume_attenuation_distance: 15.0 + thin_walled: false + double_sided: false +GlassFrosted: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 0.0 + roughness: 1.0 + ior: 1.5 + transmission: 0.94 + specular_strength: 1.0 + volume_thickness: 1.0 + volume_attenuation_color: [0.86, 0.94, 0.97] + volume_attenuation_distance: 2.5 + thin_walled: false + double_sided: false +GlassSmoke: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 0.0 + roughness: 1.0 + ior: 1.52 + transmission: 0.98 + specular_strength: 1.0 + volume_thickness: 1.0 + volume_attenuation_color: [0.44, 0.53, 0.64] + volume_attenuation_distance: 0.45 + thin_walled: false + double_sided: false +SkinHumanLight: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 0.0 + roughness: 1.0 + ior: 1.4 + specular_color: [1.0, 0.96, 0.92] + specular_strength: 0.48 + sheen_color: [0.14, 0.035, 0.022] + sheen_roughness: 0.58 + clearcoat: 0.1 + clearcoat_roughness: 0.3 + diffuse_transmission_color: [1.0, 0.24, 0.14] + diffuse_transmission_strength: 0.16 + double_sided: false +SoapBubble: + base_color: [1.0, 1.0, 1.0, 1.0] + metallic: 0.0 + roughness: 0.075 + ior: 1.33 + transmission: 1.0 + specular_strength: 1.0 + emissive: [1.0, 1.0, 1.0] + emission_intensity: 0.32 + volume_thickness: 1.0 + volume_attenuation_color: [0.9, 0.85, 0.84] + volume_attenuation_distance: 3.0 + iridescence_factor: 1.0 + iridescence_ior: 1.75 + iridescence_reflection_boost: 2.6 + iridescence_min_reflection: 0.045 + iridescence_thickness: [250.0, 520.0] + thin_walled: false + double_sided: false +``` + +### Bubble and skin geometry + +`SoapBubble` binds color, AO, metallic, iridescence-thickness and emissive maps. +The explicit auxiliary `SoapBubbleInterior` reuses the outer resource's color, +AO, metallic and normal maps. It has roughness 0.035, transmission 1, IOR 1.33, +zero emission and zero iridescence factor. Its attenuation color is +`[0.90, 0.85, 0.84]`, attenuation distance is 3, and volume thickness is 1. +Both interfaces use `thin_walled=false` and `double_sided=false`. The preview +creates a smaller inward-facing inner shell; the factory only creates materials. + +`SkinHumanLight` approximates skin using texture detail, specular reflection, +sheen, clearcoat and diffuse transmission. It does not implement random-walk +subsurface scattering. The portrait preview uses an optional caller-provided +segmented `SkinFaceDemo` GLB; that mesh is not included in the material archive. +Without `--model`, the preview renders the same skin material on a sphere. + +## Query, cache and reproduce + +```python +from embodichain.data import list_default_materials, get_default_material_info + +names = list_default_materials() # 24 public presets, no World or download +info = get_default_material_info("PlasticMatteBlue") +assert info["source"] == "builtin" +assert info["requires_download"] is False +print(info["parameters"]) +``` + +`SoapBubbleInterior` is available by name but omitted from the public list. +Built-ins have no texture resolution or nominal texture size. Resource metadata +contains dimensions, compatible renderers, dependencies and the pinned resource +revision. Both sources share the same runtime/Spawn resolver and UID rules. +NoRender retains declarations without downloading or loading native materials. + +```bash +# Offline catalog query. +embodichain preview-material --list + +# Only the six built-in parameter materials. +embodichain preview-material \ + --all --headless --source builtin --renderer rt --compare-native --size 640 --frames 6 --spp 12 --gallery + +# All 24 presets; add --model /path/to/skin_face.glb for the portrait sample. +embodichain preview-material \ + --all --headless --renderer rt --compare-native --size 640 --frames 6 --spp 12 --gallery + +# Pre-download the 18 resource presets and the bubble helper. +embodichain data download --name DefaultMaterials +``` + +Use `embodichain preview-material --material GlassClear` for a native window, +or add `--headless` to export and exit. See the +{doc}`preview-material guide ` for parameter overrides, +compatible batch selection and output metadata. The existing example delegates +to the same implementation and retains its headless default. + +The native-comparison preview saves lossless `*_preset.png`, `*_native.png` and +`*_native_repeat.png` images plus `results.json`. Direct-native comparisons use +the original resource directory or the built-in native parameter definition, +with the same geometry, camera, exposure and lights. Material parameter readback +and center-crop image differences are checked against a native-repeat control. +The documentation images are compressed WebP exports of the preset captures. + +The resource archive is currently hosted on the DexSim internal resource server. +To use a mirror, set `EMBODICHAIN_DOWNLOAD_PREFIX` before importing EmbodiChain +and provide the identical archive at +`materials/default-materials/ec9f748242c50b85/offlinert_materials.zip` beneath +your prefix. Built-in parameter definitions remain available in the wheel. +The resource revision, file digests and upstream attribution are recorded in +`embodichain/data/default_materials.json`. diff --git a/embodichain/_material_catalog.py b/embodichain/_material_catalog.py new file mode 100644 index 0000000000..3c39a83e75 --- /dev/null +++ b/embodichain/_material_catalog.py @@ -0,0 +1,77 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Offline catalog for the versioned DexSim default material collection.""" + +from __future__ import annotations + +import copy +import json +from functools import lru_cache +from importlib.resources import files +from typing import Any + +__all__ = ["list_default_materials", "get_default_material_info"] + + +@lru_cache(maxsize=1) +def _catalog() -> dict[str, Any]: + return json.loads( + files("embodichain") + .joinpath("data", "default_materials.json") + .read_text(encoding="utf-8") + ) + + +def list_default_materials() -> list[str]: + """List the 24 public presets without downloading assets or creating a World. + + Returns: + Preset names in catalog order. Auxiliary resources are omitted. + """ + return [item["name"] for item in _catalog()["materials"] if not item["auxiliary"]] + + +def get_default_material_info(name: str) -> dict[str, Any]: + """Read independent metadata for a public preset or auxiliary resource. + + Args: + name: Case-sensitive catalog name, including ``SoapBubbleInterior``. + + Returns: + Description, source, download requirement, supported renderers, + dependencies, geometry guidance and source revision. Built-in presets + also include their native parameter defaults and have no texture size. + Suggested resource sizes do not change UVs. + + Raises: + ValueError: The name is not in the built-in catalog. + """ + for item in _catalog()["materials"]: + if item["name"] == name: + result = copy.deepcopy(item) + result.setdefault("source", "resource") + result["requires_download"] = result["source"] != "builtin" + result["texture_resolution"] = ( + list(_catalog()["texture_resolution"]) + if result["requires_download"] + else None + ) + result.setdefault("revision", _catalog()["bundle"]["revision"]) + return result + raise ValueError( + f"Unknown default material {name!r}. Available presets: {', '.join(list_default_materials())}." + ) diff --git a/embodichain/cli/main.py b/embodichain/cli/main.py index 0e1a2df126..8a72100137 100644 --- a/embodichain/cli/main.py +++ b/embodichain/cli/main.py @@ -72,6 +72,11 @@ class Command: target="embodichain.lab.scripts.preview_robot:cli", help="Preview a robot with control-part Gizmos and Viser joint controls.", ), + Command( + name="preview-material", + target="embodichain.lab.scripts.preview_material:cli", + help="Preview default materials or export rendered images and galleries.", + ), Command( name="run-task", target="embodichain.lab.scripts.run_task:cli", diff --git a/embodichain/data/__init__.py b/embodichain/data/__init__.py index 69479b0098..668bf2cb0a 100644 --- a/embodichain/data/__init__.py +++ b/embodichain/data/__init__.py @@ -16,6 +16,8 @@ """Dataset resolution, asset-download helpers, preset asset registries, and shared constants/enums used by simulation tasks and training pipelines.""" +from __future__ import annotations + import os from .constants import EMBODICHAIN_DEFAULT_DATABASE_ROOT @@ -27,3 +29,4 @@ from . import assets from .dataset import * +from .default_materials import list_default_materials, get_default_material_info diff --git a/embodichain/data/assets/materials.py b/embodichain/data/assets/materials.py index a627e26738..80dad36a72 100644 --- a/embodichain/data/assets/materials.py +++ b/embodichain/data/assets/materials.py @@ -14,11 +14,16 @@ # limitations under the License. # ---------------------------------------------------------------------------- +from __future__ import annotations + import os +import hashlib +import shutil +import zipfile import open3d as o3d from pathlib import Path -from typing import List +from typing import ClassVar, List from embodichain.data.dataset import EmbodiChainDataset from embodichain.utils import logger @@ -26,10 +31,126 @@ EMBODICHAIN_DOWNLOAD_PREFIX, EMBODICHAIN_DEFAULT_DATA_ROOT, ) +from embodichain.data.default_materials import _catalog, get_default_material_info + +__all__ = [ + "SimResources", + "EnvMapHDR", + "CocoBackground", + "BackgroundTexture", + "DefaultMaterials", +] material_assets = "materials" +class DefaultMaterials(EmbodiChainDataset): + """Lazily download the pinned default-material collection. + + Args: + data_root: Optional data cache root. Each resource revision has its own + locked cache entry. ``EMBODICHAIN_DOWNLOAD_PREFIX`` selects a mirror + containing the catalog's versioned archive path; otherwise the + original DexSim resource server is used (requires network access). + """ + + cache_prefix: ClassVar[str] = f"DefaultMaterials_{_catalog()['bundle']['revision']}" + """Revision-specific cache identity, also used by the data CLI.""" + + def __init__(self, data_root: str | None = None) -> None: + bundle = _catalog()["bundle"] + url = ( + EMBODICHAIN_DOWNLOAD_PREFIX + bundle["mirror_path"] + if os.environ.get("EMBODICHAIN_DOWNLOAD_PREFIX") + else bundle["upstream_url"] + ) + root = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root + prefix = self.cache_prefix + super().__init__(prefix, o3d.data.DataDescriptor(url, bundle["md5"]), root) + archive = Path(self.download_dir) / bundle["archive_name"] + if self._sha256(archive) != bundle["sha256"]: + raise ValueError( + "DefaultMaterials archive does not match its pinned SHA256." + ) + self._verify_files(Path(self.extract_dir)) + + @staticmethod + def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + @classmethod + def _verify_files(cls, root: Path) -> None: + for name, info in _catalog()["bundle"]["files"].items(): + path = root / name + if ( + not path.is_file() + or path.stat().st_size != info["size_bytes"] + or cls._sha256(path) != info["sha256"] + ): + raise ValueError( + f"Incomplete or corrupt DefaultMaterials resource: {path}." + ) + + def check_zip( + self, prefix: str, data_descriptor: o3d.data.DataDescriptor, path: str + ) -> None: + """Validate or repair this revision while the dataset lock is held. + + Args: + prefix: Versioned cache entry owned by this dataset. + data_descriptor: Native download descriptor with the pinned MD5. + path: Data cache root, including caller-provided roots. + """ + # Called under EmbodiChainDataset's existing interprocess lock. Repair + # only this revision's extraction, including caches with custom roots. + archive = ( + Path(path) / "download" / prefix / _catalog()["bundle"]["archive_name"] + ) + extracted = Path(path) / "extract" / prefix + if not archive.is_file(): + if extracted.exists(): + shutil.rmtree(extracted) + return + if self._sha256(archive) != _catalog()["bundle"]["sha256"]: + archive.unlink() + if extracted.exists(): + shutil.rmtree(extracted) + return + try: + self._verify_files(extracted) + except ValueError: + if extracted.exists(): + shutil.rmtree(extracted) + # The archive was verified against a built-in immutable digest. + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(extracted) + + def get_material_path(self, name: str) -> str: + """Get an authored preset directory in this verified resource revision. + + Args: + name: Downloadable resource preset or ``SoapBubbleInterior``. + + Returns: + Absolute path suitable for DexSim's native material loader. + + Raises: + ValueError: The name is unknown or denotes a built-in parameter + preset, which has no directory in this archive. + """ + info = get_default_material_info(name) + if not info["requires_download"]: + raise ValueError( + f"{name!r} is a built-in parameter preset with no resource directory; " + "use VisualMaterialCfg.from_preset()." + ) + return str(Path(self.extract_dir) / "Material" / name) + + class SimResources(EmbodiChainDataset): def __init__(self, data_root: str = None): data_descriptor = o3d.data.DataDescriptor( diff --git a/embodichain/data/default_materials.json b/embodichain/data/default_materials.json new file mode 100644 index 0000000000..eee19ffd3b --- /dev/null +++ b/embodichain/data/default_materials.json @@ -0,0 +1,995 @@ +{ + "schema_version": 1, + "texture_resolution": [ + 1024, + 1024 + ], + "filename_pattern": "{name}_{channel}.png", + "auto_loaded_channels": [ + "Color", + "AmbientOcclusion", + "Roughness", + "Metallic", + "Normal" + ], + "authoring_only_channels": [ + "Displacement" + ], + "advanced_parameters_manifest": "material.json", + "advanced_texture_channels": [ + "Transmission", + "Clearcoat", + "ClearcoatRoughness", + "ClearcoatNormal", + "SheenColor", + "SheenRoughness", + "Iridescence", + "IridescenceThickness", + "SpecularColor", + "SpecularStrength", + "DiffuseTransmissionColor", + "DiffuseTransmissionStrength", + "VolumeAttenuationStrength" + ], + "materials": [ + { + "name": "BrickRed", + "category": "architecture", + "description": "Red fired brick with gray mortar", + "nominal_size_m": [ + 1.2, + 0.84 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "WoodOak", + "category": "wood", + "description": "Warm oak wood grain", + "nominal_size_m": [ + 0.8, + 0.8 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "CeramicTileWhite", + "category": "architecture", + "description": "White glazed ceramic tiles with gray grout", + "nominal_size_m": [ + 1.0, + 1.0 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "MarbleWhite", + "category": "stone", + "description": "Polished white marble with gray veins", + "nominal_size_m": [ + 1.5, + 1.5 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "AsphaltDark", + "category": "ground", + "description": "Dark asphalt with fine aggregate", + "nominal_size_m": [ + 2.0, + 2.0 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "FabricGray", + "category": "fabric", + "description": "Fine woven gray fabric", + "nominal_size_m": [ + 0.25, + 0.25 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "LeatherBrown", + "category": "leather", + "description": "Brown grained leather", + "nominal_size_m": [ + 0.35, + 0.35 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "PlasticABSBlack", + "category": "polymer", + "description": "Black ABS plastic with a subtle orange-peel finish", + "nominal_size_m": [ + 0.25, + 0.25 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "RubberBlack", + "category": "polymer", + "description": "Rough black rubber", + "nominal_size_m": [ + 0.25, + 0.25 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "SteelBrushed", + "category": "metal", + "description": "Directional brushed steel", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "ChromePolished", + "category": "metal", + "description": "Highly reflective polished chrome", + "nominal_size_m": [ + 0.35, + 0.35 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "GoldPolished", + "category": "metal", + "description": "Polished gold", + "nominal_size_m": [ + 0.35, + 0.35 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "CopperPolished", + "category": "metal", + "description": "Polished copper", + "nominal_size_m": [ + 0.35, + 0.35 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "GlassClear", + "category": "glass", + "description": "Clear solid glass with weak volume absorption", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "GlassFrosted", + "category": "glass", + "description": "Rough transmitting frosted glass", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "GlassSmoke", + "category": "glass", + "description": "Gray-blue absorbing smoke glass", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "", + "auxiliary_resources": [] + }, + { + "name": "SkinHumanLight", + "category": "organic", + "description": "Light human skin with pores and approximate diffuse transmission", + "nominal_size_m": [ + 0.18, + 0.18 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "Segmented face geometry belongs to the preview; this preset does not provide random-walk subsurface scattering.", + "auxiliary_resources": [] + }, + { + "name": "SoapBubble", + "category": "thin-film", + "description": "Soap film with spatially varying iridescence", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "rt" + ], + "dependencies": [], + "auxiliary": false, + "geometry_notes": "For a hollow bubble, bind SoapBubbleInterior to a separate inward-facing inner shell.", + "auxiliary_resources": [ + "SoapBubbleInterior" + ] + }, + { + "name": "SoapBubbleInterior", + "category": "thin-film", + "description": "Inward-facing auxiliary layer for a hollow soap film", + "nominal_size_m": [ + 0.5, + 0.5 + ], + "renderers": [ + "rt" + ], + "dependencies": [ + "SoapBubble" + ], + "auxiliary": true, + "geometry_notes": "Bind to a separately authored inward-facing inner shell.", + "auxiliary_resources": [] + }, + { + "name": "PlasticMatteBlue", + "category": "polymer", + "description": "Matte blue plastic", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 0.05, + 0.22, + 0.6, + 1 + ], + "metallic": 0.0, + "roughness": 0.65, + "ior": 1.5, + "transmission": 0.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0 + }, + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "" + }, + { + "name": "PlasticGlossyRed", + "category": "polymer", + "description": "Glossy red plastic", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 0.55, + 0.025, + 0.025, + 1 + ], + "metallic": 0.0, + "roughness": 0.08, + "ior": 1.5, + "transmission": 0.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0 + }, + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "" + }, + { + "name": "GoldMetal", + "category": "metal", + "description": "Gold-colored metal", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 0.95, + 0.76, + 0.32, + 1 + ], + "metallic": 1.0, + "roughness": 0.22, + "ior": 1.5, + "transmission": 0.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0 + }, + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "" + }, + { + "name": "MirrorMetal", + "category": "metal", + "description": "Smooth silver-colored metallic mirror", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 0.95, + 0.95, + 0.95, + 1 + ], + "metallic": 1.0, + "roughness": 0, + "ior": 1.5, + "transmission": 0.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0 + }, + "renderers": [ + "hybrid", + "fast-rt", + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "" + }, + { + "name": "GlassClearSurface", + "category": "glass", + "description": "Clear glass with surface transmission", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 1, + 1, + 1, + 1 + ], + "metallic": 0.0, + "roughness": 0, + "ior": 1.5, + "transmission": 1.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0, + "thin_walled": true + }, + "renderers": [ + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "Surface-only transmission; no volume absorption is configured." + }, + { + "name": "GlassFrostedSurface", + "category": "glass", + "description": "Frosted glass with rough surface transmission", + "source": "builtin", + "revision": "builtin-v1", + "parameters": { + "base_color": [ + 1, + 1, + 1, + 1 + ], + "metallic": 0.0, + "roughness": 0.28, + "ior": 1.5, + "transmission": 1.0, + "emissive": [ + 0, + 0, + 0 + ], + "emission_intensity": 1.0, + "thin_walled": true + }, + "renderers": [ + "rt" + ], + "nominal_size_m": null, + "dependencies": [], + "auxiliary": false, + "auxiliary_resources": [], + "geometry_notes": "Surface-only transmission; no volume absorption is configured." + } + ], + "bundle": { + "revision": "ec9f748242c50b85", + "source_repository": "http://192.168.3.16/Engine/dexverse.git", + "source_commit": "c31dccd3d4a8ef98367e1a95c92c842e0a773596", + "source_path": "modules/dexsim/resources/Material", + "upstream_url": "http://192.168.3.120/CoreEngine/DexSim/resources/offlinert_materials.zip", + "mirror_path": "materials/default-materials/ec9f748242c50b85/offlinert_materials.zip", + "archive_name": "offlinert_materials.zip", + "md5": "f9795c6706dbe0540052e0df9b5b835b", + "sha256": "ec9f748242c50b85b9a99d068836d7231570dfca51f788b9e005b6022c92623d", + "size_bytes": 29949936, + "attribution": "DexForce DexSim OfflineRT material collection. Authored source README and manifests are retained unmodified in the downloaded archive. No separate asset license is present in that source archive.", + "files": { + "Material/AsphaltDark/AsphaltDark_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/AsphaltDark/AsphaltDark_Color.png": { + "size_bytes": 466078, + "sha256": "bea2dd69577b3058c19ef708ee475205a9fd57cc5cc1d7eabea0b904e2b9f2c5" + }, + "Material/AsphaltDark/AsphaltDark_Roughness.png": { + "size_bytes": 391237, + "sha256": "eff065f9b6a15f484c1abd64b11b0e5718cf903323e3a995085ba82e62879119" + }, + "Material/AsphaltDark/AsphaltDark_Normal.png": { + "size_bytes": 2154019, + "sha256": "6c99d78194745b907b87eefab4b701c3491fffa7fab0f3d90c76ccecf516baba" + }, + "Material/AsphaltDark/AsphaltDark_AmbientOcclusion.png": { + "size_bytes": 675541, + "sha256": "07e1298054571e824223e1af8ed6944b511e8db2d152cf6c74e1d4e803613b56" + }, + "Material/BrickRed/BrickRed_AmbientOcclusion.png": { + "size_bytes": 85111, + "sha256": "dc5c63d3cf26237bdd6043cc92db569cb7ccc8ec5ade8c677eb4fc5e7818251c" + }, + "Material/BrickRed/BrickRed_Normal.png": { + "size_bytes": 898525, + "sha256": "1bed5717d9bfd699348370599962d22c36b00725bfd0d6b59ca9aa72594b7979" + }, + "Material/BrickRed/BrickRed_Roughness.png": { + "size_bytes": 414644, + "sha256": "c5d093082d6d4f08f940a9dc0f47db2694b9b0a50ef70eef1c768b3361a2420c" + }, + "Material/BrickRed/BrickRed_Color.png": { + "size_bytes": 562205, + "sha256": "e9d0e89823ed8ae153a22ea01deefe6eebb7ba94e192b643ae99b374a70b2a55" + }, + "Material/BrickRed/BrickRed_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/CeramicTileWhite/CeramicTileWhite_Roughness.png": { + "size_bytes": 129300, + "sha256": "9d0472faf51b0c1e6c3990ebb5abbc70de6f313eda0b71b352efee2684788247" + }, + "Material/CeramicTileWhite/CeramicTileWhite_Normal.png": { + "size_bytes": 168738, + "sha256": "566e01fff54e48f42b2002cfd4126be229e8f9f6ed0dc2730119ca4e721bace4" + }, + "Material/CeramicTileWhite/CeramicTileWhite_Color.png": { + "size_bytes": 372086, + "sha256": "622aad55cef21f88b6dca44075c29c745793f4e4cedd435cd5abfe8bd4fa6d7c" + }, + "Material/CeramicTileWhite/CeramicTileWhite_AmbientOcclusion.png": { + "size_bytes": 22670, + "sha256": "0bad3852069f50dad9495edc548efd70968b55a1e186e842d39247c264165cb1" + }, + "Material/CeramicTileWhite/CeramicTileWhite_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/ChromePolished/ChromePolished_Normal.png": { + "size_bytes": 75414, + "sha256": "3ef74083f5694fb2f7decba181bc79aec8592d2e9d2c0f247f9f9f3fcb10597f" + }, + "Material/ChromePolished/ChromePolished_Color.png": { + "size_bytes": 104437, + "sha256": "c0a7d4f30a646773fbb715d61ba444e99593a8523a3a17f7ca408802a76c0bec" + }, + "Material/ChromePolished/material.json": { + "size_bytes": 588, + "sha256": "0c442d4dc141087ef27faa32ca81e2b5efee16584f741945cb39b721efad553d" + }, + "Material/ChromePolished/ChromePolished_Roughness.png": { + "size_bytes": 50171, + "sha256": "5c83a706792780a794627228dc82d8cc411f40f52e1e1ff4d23b1f78d5b32376" + }, + "Material/ChromePolished/ChromePolished_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/ChromePolished/ChromePolished_Metallic.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/CopperPolished/CopperPolished_Metallic.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/CopperPolished/CopperPolished_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/CopperPolished/CopperPolished_Normal.png": { + "size_bytes": 75606, + "sha256": "e8d51e304169383892367c6927b7b7ff1768fb2c7a3153d91190912994925f03" + }, + "Material/CopperPolished/material.json": { + "size_bytes": 588, + "sha256": "b40e24ae03f0251b0a1fcd96793e8ba23fb1b136951fc11936fd75328d4ace31" + }, + "Material/CopperPolished/CopperPolished_Roughness.png": { + "size_bytes": 51047, + "sha256": "a4a574b0dd11dee7e42156311e78ae6deeea7fee359f6c5fbb317eb15be2a3d4" + }, + "Material/CopperPolished/CopperPolished_Color.png": { + "size_bytes": 76739, + "sha256": "d44db86e838209c2be3c6de226009462d1c082aff015e6a88efa7e555444a266" + }, + "Material/FabricGray/FabricGray_Normal.png": { + "size_bytes": 1630990, + "sha256": "7086a519f162ddce3a4bad589dad4c906b15c05580a5a8c7b69612ebde55aea6" + }, + "Material/FabricGray/FabricGray_Color.png": { + "size_bytes": 809076, + "sha256": "adf4f17f9bc682b0aa7ed65386e33984d7522a88ec9304a97790f9343665271c" + }, + "Material/FabricGray/FabricGray_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/FabricGray/FabricGray_Roughness.png": { + "size_bytes": 91537, + "sha256": "0cf32a52d8b13cbf2587ff2b0d09fb1a63cc5a5c4787c28e5097df0c1379b9ca" + }, + "Material/FabricGray/FabricGray_AmbientOcclusion.png": { + "size_bytes": 671034, + "sha256": "18b830ad44d26c8f24a203cdd6fcebb4d317df0b2b9263f36beb779505f8f719" + }, + "Material/GlassClear/GlassClear_Color.png": { + "size_bytes": 65512, + "sha256": "6ff83db7f08435e85570f7880aa3c6df0a3f6f974461f1d35427fb2b2d921c24" + }, + "Material/GlassClear/GlassClear_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/GlassClear/GlassClear_Normal.png": { + "size_bytes": 252032, + "sha256": "5f6c92db4d79ab04b76645c04c6577891912ca53d632e2dea31f8b16b1ba46c2" + }, + "Material/GlassClear/GlassClear_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/GlassClear/material.json": { + "size_bytes": 727, + "sha256": "27a838dc38fdac24a1b3e863e1e9be29d069fafb1a32b7325661c69bb52c02b1" + }, + "Material/GlassClear/GlassClear_Roughness.png": { + "size_bytes": 68873, + "sha256": "d3dbd730411489ffa27036c9af60eea8ad90c8b897142721c6c78aa0a3f6b2e8" + }, + "Material/GlassFrosted/GlassFrosted_Roughness.png": { + "size_bytes": 447338, + "sha256": "6c2beba522e89035c8bbeac0eff8af15bbbd5973e8ba12cc2071608c05017c32" + }, + "Material/GlassFrosted/GlassFrosted_Normal.png": { + "size_bytes": 1194686, + "sha256": "cdd2236c318afdd65f485b3c7c2a8994dbb8903bf7cc273d99a0f3fdda34630d" + }, + "Material/GlassFrosted/material.json": { + "size_bytes": 736, + "sha256": "989915da70644307eabf04c124a94f89b98d764c27b0c4597a53d595e7d8e0dd" + }, + "Material/GlassFrosted/GlassFrosted_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/GlassFrosted/GlassFrosted_AmbientOcclusion.png": { + "size_bytes": 239848, + "sha256": "1f68a1a0f666af54a7e03dcf7b28b9dbf6bc6ba4bfb86e0b1a7359e7cc509ad2" + }, + "Material/GlassFrosted/GlassFrosted_Color.png": { + "size_bytes": 478134, + "sha256": "473de77fc1435254292c1c225fb8bc0a95e8b0a74bf2db12ed758cce84dec48b" + }, + "Material/GlassSmoke/GlassSmoke_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/GlassSmoke/GlassSmoke_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/GlassSmoke/GlassSmoke_Roughness.png": { + "size_bytes": 41208, + "sha256": "516966964eea378c08ae7774cc6fdd8d08151e6098d4d299945acd0f6fd81179" + }, + "Material/GlassSmoke/GlassSmoke_Normal.png": { + "size_bytes": 31503, + "sha256": "13290872674bd8d589e41573f1693f61c8f849509d22b1317363b8615ad1c9f8" + }, + "Material/GlassSmoke/GlassSmoke_Color.png": { + "size_bytes": 266645, + "sha256": "5c491910029480e132751a7b0ef30fa21d3a056a86cfc1da22ad261cabca35c4" + }, + "Material/GlassSmoke/material.json": { + "size_bytes": 715, + "sha256": "0e451e3b6234cb7b96e9ee6ae3ce425a1735b28eff7eb72cb7f12eae825380e9" + }, + "Material/GoldPolished/GoldPolished_Color.png": { + "size_bytes": 85516, + "sha256": "70fe1ac0c815a9ab30ff26316e877f3a1d182feb3e646f1c65d458ef82b3ebd3" + }, + "Material/GoldPolished/material.json": { + "size_bytes": 581, + "sha256": "0494435ed2afbff44e72e2fd3033728399c431218fd4696da79152bfb40e900c" + }, + "Material/GoldPolished/GoldPolished_Metallic.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/GoldPolished/GoldPolished_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/GoldPolished/GoldPolished_Roughness.png": { + "size_bytes": 51250, + "sha256": "9e9d43ab5258510ef0a7415dd58a3f02e921418a868ef9552d9f0ecd8480868f" + }, + "Material/GoldPolished/GoldPolished_Normal.png": { + "size_bytes": 77243, + "sha256": "5ba353c25e94a19bb5f2e39dee0592381c307eebadb4a020d8a21f1f0d4ad2f6" + }, + "Material/LeatherBrown/LeatherBrown_Color.png": { + "size_bytes": 806290, + "sha256": "e7bc2d28da759fdc913957fb5153016be17b90eb750acdb9d22d775360bb5964" + }, + "Material/LeatherBrown/LeatherBrown_Roughness.png": { + "size_bytes": 568256, + "sha256": "ff7ca26cc10953d75c92f46e095138000eaef1c898332db07e944f3ea932b3e3" + }, + "Material/LeatherBrown/LeatherBrown_Normal.png": { + "size_bytes": 2081908, + "sha256": "48ccd2055fba5e13d762fc38fab4863f726725ac7d2e751b8c48a9c61a6d5bfa" + }, + "Material/LeatherBrown/LeatherBrown_AmbientOcclusion.png": { + "size_bytes": 600787, + "sha256": "86a870c4b81d8de4469c66fef116f8d2b7569f880d4972f0cd06ea63a4aca077" + }, + "Material/LeatherBrown/LeatherBrown_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/MarbleWhite/MarbleWhite_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/MarbleWhite/MarbleWhite_AmbientOcclusion.png": { + "size_bytes": 184011, + "sha256": "1d9e608a2df6507cc61c1d411e5408a0f85f214b64297f777b400bd9ed4a581b" + }, + "Material/MarbleWhite/MarbleWhite_Color.png": { + "size_bytes": 621517, + "sha256": "abda3d9b580bfff628d646d51ab1eda7ae91d97a63d84164b7b30c4372436026" + }, + "Material/MarbleWhite/MarbleWhite_Roughness.png": { + "size_bytes": 274246, + "sha256": "0923211c80300550b56071c7c14842db9820274413b19916de914c398cbfb960" + }, + "Material/MarbleWhite/MarbleWhite_Normal.png": { + "size_bytes": 500754, + "sha256": "915aeb7392a1d7d07a96e7136f5cadbd0d5596933fe48fced56b8b9eaab1e5e9" + }, + "Material/PlasticABSBlack/PlasticABSBlack_Normal.png": { + "size_bytes": 1049698, + "sha256": "e8a485e73cbb5e7945fa8d34544f4d84816f9b82807736eeb46e745379e6513b" + }, + "Material/PlasticABSBlack/PlasticABSBlack_Roughness.png": { + "size_bytes": 445656, + "sha256": "11d0b71bde236cbc6012da93d75fe0ab99b48c67e3cf08c3caa0cf300b4c32fa" + }, + "Material/PlasticABSBlack/PlasticABSBlack_Color.png": { + "size_bytes": 184591, + "sha256": "e8d5deda3a482651222bc81780357c0d88e10e397a5bfa06a9d04d199aad248f" + }, + "Material/PlasticABSBlack/PlasticABSBlack_AmbientOcclusion.png": { + "size_bytes": 335027, + "sha256": "8b173f7f8f2ef1127b89221d6b8590ec96063d0fb670d19169d7f1d5046f3dd0" + }, + "Material/PlasticABSBlack/PlasticABSBlack_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/RubberBlack/RubberBlack_AmbientOcclusion.png": { + "size_bytes": 430869, + "sha256": "fc706946fa890601e9c0ad132a61f7b19ec7c9cb6b680fb7fcf67e4ec3bb58ac" + }, + "Material/RubberBlack/RubberBlack_Roughness.png": { + "size_bytes": 421041, + "sha256": "225277d265b364626d17b34049fea19fa3754150c1e1fe30b33eef89d929a3e8" + }, + "Material/RubberBlack/RubberBlack_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/RubberBlack/RubberBlack_Normal.png": { + "size_bytes": 1960731, + "sha256": "01798359c3ee69383fa45648c73fb738c11ad7bafe202dd28caa14cc8e289887" + }, + "Material/RubberBlack/RubberBlack_Color.png": { + "size_bytes": 276578, + "sha256": "a7b9c2e1894e1ce6401d7061502b5e672dd56b82f44dbe3f5c8befd57d3404d9" + }, + "Material/SkinHumanLight/SkinHumanLight_AmbientOcclusion.png": { + "size_bytes": 153206, + "sha256": "f598b68ec9115fc7b6b27b281bc68aada28687fd8c0791a73ca74f1911c2577a" + }, + "Material/SkinHumanLight/SkinHumanLight_Roughness.png": { + "size_bytes": 549626, + "sha256": "30e940d268b970a03dfdd7cb63628cfbc06acda6aa9d22b584329085cf0b75be" + }, + "Material/SkinHumanLight/SkinHumanLight_Color.png": { + "size_bytes": 195646, + "sha256": "ce5bacdcd87a1e8784ef2191e960119bd8738d906c7e85f9722557f31c1ae97a" + }, + "Material/SkinHumanLight/material.json": { + "size_bytes": 861, + "sha256": "a2ff37eb591a8275e955f027b4817f9f247b853169e7ac4a8304e8e87d67286c" + }, + "Material/SkinHumanLight/SkinHumanLight_Normal.png": { + "size_bytes": 526198, + "sha256": "9f64070801c85286d44557b7572d36bf0456d3b2161190a292abb34149011768" + }, + "Material/SkinHumanLight/SkinHumanLight_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/SoapBubble/SoapBubble_Normal.png": { + "size_bytes": 781481, + "sha256": "50e93086ae22c4e89cc64a78259afe60b10d16f1a53e1d99e92c75eab7a669a2" + }, + "Material/SoapBubble/SoapBubble_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/SoapBubble/SoapBubble_Color.png": { + "size_bytes": 29583, + "sha256": "38524c66c715c56f768df86d3d0f1eacf40b093cc1bba12246d1169ce131f529" + }, + "Material/SoapBubble/SoapBubble_IridescenceColor.png": { + "size_bytes": 666142, + "sha256": "478be3eaeaab24c4c7a09f577a0ad68501c195df16f132136d9b151f0dfc814c" + }, + "Material/SoapBubble/material.json": { + "size_bytes": 1124, + "sha256": "eae22fa44d9f417a595064f246e27b39ab5fded9b92a059821b0d4d5b86f7c9f" + }, + "Material/SoapBubble/SoapBubble_IridescenceThickness.png": { + "size_bytes": 180816, + "sha256": "49393f845fd93248fa434ebe795d15517d41560bec19f36ddc81cd6e65a1d7ac" + }, + "Material/SoapBubble/SoapBubble_AmbientOcclusion.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/SoapBubbleInterior/material.json": { + "size_bytes": 1076, + "sha256": "aff09dae47233767fea6a8297c9f0abec8a9c483b72f70420c73ff07b8aaa4b8" + }, + "Material/SteelBrushed/SteelBrushed_AmbientOcclusion.png": { + "size_bytes": 31509, + "sha256": "cffd780fa1c0414c4d828c7a6c2e53354b1ab48c16af8c799143ce5a26592970" + }, + "Material/SteelBrushed/SteelBrushed_Normal.png": { + "size_bytes": 230534, + "sha256": "1982a6e85e0aa6db4b2187cde28c2a6ec48eba1cdb168d94c7e906b604330a02" + }, + "Material/SteelBrushed/SteelBrushed_Metallic.png": { + "size_bytes": 15886, + "sha256": "bdea23770958f6d6b4e8a984c836c77a28dc867331e4cace68414e9761d85719" + }, + "Material/SteelBrushed/SteelBrushed_Roughness.png": { + "size_bytes": 119608, + "sha256": "6dd65d00276f083e5728dd41b50bcfb61445b98bdb3c5d808921d254bd0e8337" + }, + "Material/SteelBrushed/SteelBrushed_Color.png": { + "size_bytes": 123807, + "sha256": "dad42a69cda26582a9ed64d5818f365d2502ecc3b4548b3a03e86c1f1ad56bc9" + }, + "Material/WoodOak/WoodOak_Metallic.png": { + "size_bytes": 13922, + "sha256": "6dc277ac7171be615c76ca0ffe7a721b2f0692c07b51120a0e28824da4c685ad" + }, + "Material/WoodOak/WoodOak_Color.png": { + "size_bytes": 785857, + "sha256": "560fde97a88c8df04a014c859e97fb7f00311dc0498c647045dd281c0452dea5" + }, + "Material/WoodOak/WoodOak_Roughness.png": { + "size_bytes": 359902, + "sha256": "a4dd93885fc25200e951afbbe42571fd11a1b8b4e1e1a1f596ea6f019085d85f" + }, + "Material/WoodOak/WoodOak_Normal.png": { + "size_bytes": 1014975, + "sha256": "4dd202105de185d904d08e5a8b20fd4d40e8c9b7c68a96e0fe80fa1553e4e8ff" + }, + "Material/WoodOak/WoodOak_AmbientOcclusion.png": { + "size_bytes": 277161, + "sha256": "0ad7dcf106c992d0c008279fea67a17817530496213880b3885ea748c67b4794" + }, + "Material/catalog.json": { + "size_bytes": 3975, + "sha256": "cc0c8f227a26562d2f91f0b0844099af22e6ed44211a7c4b78d3f7328ce2059e" + }, + "Material/README.md": { + "size_bytes": 4224, + "sha256": "382908eacc9a673d7ae86b53b969b921b3bb85852c9e6a7401da05fbacdf676a" + } + } + } +} diff --git a/embodichain/data/default_materials.py b/embodichain/data/default_materials.py new file mode 100644 index 0000000000..e1b65391ac --- /dev/null +++ b/embodichain/data/default_materials.py @@ -0,0 +1,27 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Offline catalog for the versioned DexSim default material collection.""" + +from __future__ import annotations + +from embodichain._material_catalog import ( + _catalog, + get_default_material_info, + list_default_materials, +) + +__all__ = ["list_default_materials", "get_default_material_info"] diff --git a/embodichain/data/download.py b/embodichain/data/download.py index b7661d15f7..67acf657c1 100644 --- a/embodichain/data/download.py +++ b/embodichain/data/download.py @@ -142,7 +142,7 @@ def download_asset(cls_name: str, cls: type) -> bool: print(f" Downloading {cls_name} ...") try: data_obj = cls() - _ensure_extract(data_obj, cls_name) + _ensure_extract(data_obj, getattr(cls, "cache_prefix", cls_name)) print(f" ✓ {cls_name} ready") return True except Exception as exc: @@ -170,10 +170,12 @@ def cmd_list(args: argparse.Namespace) -> None: sys.exit(1) print(f"\n[{category}] ({len(assets)} assets)") - for cls_name, _ in assets: + for cls_name, cls in assets: # Show whether it is already downloaded extract_dir = os.path.join( - EMBODICHAIN_DEFAULT_DATA_ROOT, "extract", cls_name + EMBODICHAIN_DEFAULT_DATA_ROOT, + "extract", + getattr(cls, "cache_prefix", cls_name), ) status = ( "✓" if os.path.isdir(extract_dir) and os.listdir(extract_dir) else " " diff --git a/embodichain/lab/gym/envs/managers/randomization/visual.py b/embodichain/lab/gym/envs/managers/randomization/visual.py index 005d2e0d76..7cfa0c0edd 100644 --- a/embodichain/lab/gym/envs/managers/randomization/visual.py +++ b/embodichain/lab/gym/envs/managers/randomization/visual.py @@ -103,6 +103,8 @@ def set_rigid_object_visual_material( mat = env.sim.create_visual_material(mat_cfg) obj: RigidObject = env.sim.get_rigid_object(entity_cfg.uid) obj.set_visual_material(mat, env_ids=env_ids, update_default=True) + for instance in obj.get_visual_material_inst(env_ids=env_ids): + mat.set_default_properties(instance, mat_cfg) def set_rigid_object_group_visual_material( @@ -141,7 +143,7 @@ def set_rigid_object_group_visual_material( mat = env.sim.create_visual_material(mat_cfg) obj: RigidObjectGroup = env.sim.get_rigid_object_group(entity_cfg.uid) - obj.set_visual_material(mat, env_ids=env_ids) + obj.set_visual_material(mat, env_ids=env_ids, reset_properties=True) # Keyed by the camera object, so envs that reuse a camera uid each warn once. @@ -693,7 +695,7 @@ def __init__(self, cfg: FunctorCfg, env: EmbodiedEnv): if texture_path is not None: from embodichain.utils.utility import read_all_folder_images - texture_key = os.path.basename(texture_path) + texture_key = str(Path(texture_path).expanduser().resolve()) # check if the texture group is already loaded in the global texture cache if texture_key in env.sim.get_texture_cache(): logger.log_info( @@ -726,7 +728,9 @@ def __init__(self, cfg: FunctorCfg, env: EmbodiedEnv): self._library_textures: list = [] self._solid_textures: list = [] self._texture_key = ( - os.path.basename(texture_path) if texture_path is not None else "" + str(Path(texture_path).expanduser().resolve()) + if texture_path is not None + else "" ) can_reuse = ( @@ -875,13 +879,15 @@ def _init_legacy(self, env: EmbodiedEnv) -> None: ) self._legacy_mat = mat if isinstance(self.entity, RigidObject): - self.entity.set_visual_material(mat) + self.entity.set_visual_material(mat, shared=self._shared) elif isinstance(self.entity, Articulation): _, link_names = resolve_matching_names( self.entity_cfg.link_names, self.entity.link_names ) self.entity_cfg.link_names = link_names - self.entity.set_visual_material(mat, link_names=link_names) + self.entity.set_visual_material( + mat, link_names=link_names, shared=self._shared + ) self._mat_insts = None if self.entity_cfg.uid == "default_plane": @@ -944,12 +950,26 @@ def _randomize_mat_inst( self._randomize_texture(mat_inst) else: - # Use the bounded solid-color palette. Uploading a generated tensor - # here would allocate a fresh DexSim texture ID on every call. - texture_idx = torch.randint(0, len(self._solid_textures), (1,)).item() - mat_inst.set_base_color_texture( - texture_obj=self._solid_textures[texture_idx] + self._apply_solid_props(mat_inst, plan, idx) + + def _selected_env_ids( + self, env: EmbodiedEnv, env_ids: torch.Tensor | None + ) -> list[int]: + if env_ids is None: + ids = list(range(env.num_envs)) + else: + if env_ids.ndim != 1 or env_ids.dtype not in (torch.int32, torch.int64): + raise ValueError( + "Material environment indices must be a 1D integer tensor." + ) + ids = env_ids.detach().cpu().tolist() + if len(set(ids)) != len(ids) or any(i < 0 or i >= env.num_envs for i in ids): + raise ValueError( + "Material environment indices must be unique and in range." ) + if ids and self._shared and len(ids) != env.num_envs: + raise ValueError("Shared material randomization requires all environments.") + return ids def __call__( self, @@ -995,12 +1015,7 @@ def _call_reuse( ) -> None: if self.entity is None: return - if env_ids is None: - env_ids = torch.arange(env.num_envs, device="cpu") - else: - env_ids = env_ids.cpu() - - selected_env_ids = [int(env_idx) for env_idx in env_ids] + selected_env_ids = self._selected_env_ids(env, env_ids) num_reuse = len(self._reuse_state) # 1 if shared, else num_envs if num_reuse == 0 or not selected_env_ids: return @@ -1011,10 +1026,14 @@ def _call_reuse( is_articulation = isinstance(self.entity, Articulation) tiers = None if is_articulation else self._sample_tiers(num_plan) articulation_tiers = ( - self._sample_articulation_tiers() if is_articulation else None + self._sample_articulation_tiers([0] if self._shared else selected_env_ids) + if is_articulation + else None ) - def _apply(reuse_i: int, env_idx: int, plan_i: int) -> None: + def _apply( + reuse_i: int, env_idx: int, plan_i: int, update_properties: bool = True + ) -> None: if is_articulation: self._apply_tier_articulation( reuse_i, @@ -1022,21 +1041,26 @@ def _apply(reuse_i: int, env_idx: int, plan_i: int) -> None: articulation_tiers[reuse_i], plan, plan_i, + update_properties=update_properties, ) else: tier = int(tiers[plan_i].item()) self._apply_tier_rigid( - self._reuse_state[reuse_i], env_idx, tier, plan, plan_i + self._reuse_state[reuse_i], + env_idx, + tier, + plan, + plan_i, + update_properties=update_properties, ) if self._shared: - # single reuse state applied to every env - for env_idx in selected_env_ids: - _apply(0, env_idx, 0) + # Sample and mutate the shared instance once; only attachment repeats. + for i, env_idx in enumerate(selected_env_ids): + _apply(0, env_idx, 0, update_properties=i == 0) else: for plan_i, env_idx in enumerate(selected_env_ids): - reuse_i = env_idx if env_idx < num_reuse else 0 - _apply(reuse_i, env_idx, plan_i) + _apply(env_idx, env_idx, plan_i) def _sample_plan( self, num, base_color_range, metallic_range, roughness_range, ior_range @@ -1076,13 +1100,16 @@ def _sample_tiers(self, num_reuse: int) -> torch.Tensor: ) return torch.multinomial(probs, num_samples=num_reuse, replacement=True) - def _sample_articulation_tiers(self) -> list[dict[str, int]]: - """Sample an independent material tier for every articulation link.""" - tier_plans = [] - for link_map in self._reuse_state: + def _sample_articulation_tiers( + self, env_ids: list[int] + ) -> dict[int, dict[str, int]]: + """Sample only selected environments and their articulation links.""" + tier_plans = {} + for env_idx in env_ids: + link_map = self._reuse_state[env_idx] link_names = list(link_map) sampled = self._sample_tiers(len(link_names)).tolist() - tier_plans.append(dict(zip(link_names, sampled))) + tier_plans[env_idx] = dict(zip(link_names, sampled)) return tier_plans def _apply_inst(self, env_idx, mat_inst, mesh_id, link_name=None) -> None: @@ -1094,16 +1121,19 @@ def _apply_inst(self, env_idx, mat_inst, mesh_id, link_name=None) -> None: env_idx, mat_inst, link_name, mesh_id ) - def _apply_tier_rigid(self, segments, env_idx, tier, plan, idx) -> None: + def _apply_tier_rigid( + self, segments, env_idx, tier, plan, idx, *, update_properties: bool = True + ) -> None: if not segments: return if tier == 0: # original self._restore_original(segments, env_idx) return - if tier == 1 and self._library_textures: # library - self._apply_library_props(segments[0].working_inst, plan, idx) - else: # solid (or library with empty library) - self._apply_solid_props(segments[0].working_inst, plan, idx) + if update_properties: + if tier == 1 and self._library_textures: # library + self._apply_library_props(segments[0].working_inst, plan, idx) + else: # solid (or library with empty library) + self._apply_solid_props(segments[0].working_inst, plan, idx) self._attach_working(segments, env_idx) def _apply_library_props(self, working_inst, plan, idx) -> None: @@ -1114,8 +1144,14 @@ def _apply_library_props(self, working_inst, plan, idx) -> None: def _apply_solid_props(self, working_inst, plan, idx) -> None: texture_idx = torch.randint(0, len(self._solid_textures), (1,)).item() working_inst.set_base_color([1.0, 1.0, 1.0, 1.0]) - working_inst.set_metallic(0.0) - working_inst.set_roughness(0.7) + working_inst.set_metallic( + plan["metallic"][idx].item() if "metallic" in plan else 0.0 + ) + working_inst.set_roughness( + plan["roughness"][idx].item() if "roughness" in plan else 0.7 + ) + if "ior" in plan: + working_inst.set_ior(plan["ior"][idx].item()) working_inst.set_base_color_texture( texture_obj=self._solid_textures[texture_idx] ) @@ -1139,7 +1175,9 @@ def _apply_plan_props(self, working_inst, plan, idx) -> None: if "ior" in plan: working_inst.set_ior(plan["ior"][idx].item()) - def _apply_tier_articulation(self, reuse_i, env_idx, tiers, plan, plan_i) -> None: + def _apply_tier_articulation( + self, reuse_i, env_idx, tiers, plan, plan_i, *, update_properties: bool = True + ) -> None: link_map = self._reuse_state[reuse_i] # Dict[str, List[ReuseSegmentState]] for link_name, segments in link_map.items(): if not segments: @@ -1148,10 +1186,11 @@ def _apply_tier_articulation(self, reuse_i, env_idx, tiers, plan, plan_i) -> Non if tier == 0: # original self._restore_original(segments, env_idx, link_name) continue - if tier == 1 and self._library_textures: # library - self._apply_library_props(segments[0].working_inst, plan, plan_i) - else: # solid - self._apply_solid_props(segments[0].working_inst, plan, plan_i) + if update_properties: + if tier == 1 and self._library_textures: # library + self._apply_library_props(segments[0].working_inst, plan, plan_i) + else: # solid + self._apply_solid_props(segments[0].working_inst, plan, plan_i) self._attach_working(segments, env_idx, link_name) def _call_legacy( @@ -1168,11 +1207,9 @@ def _call_legacy( if self.entity_cfg.uid != "default_plane" and self.entity is None: return - if env_ids is None: - env_ids = torch.arange(env.num_envs, device="cpu") - else: - env_ids = env_ids.cpu() - + env_ids = self._selected_env_ids(env, env_ids) + if not env_ids: + return if self.entity_cfg.uid == "default_plane": env_ids = [0] @@ -1181,30 +1218,30 @@ def _call_legacy( base_color = sample_uniform( lower=torch.tensor(base_color_range[0], dtype=torch.float32), upper=torch.tensor(base_color_range[1], dtype=torch.float32), - size=(len(env_ids), 3), + size=(1 if self._shared else len(env_ids), 3), ) - alpha_channel = torch.ones((len(env_ids), 1), dtype=torch.float32) + alpha_channel = torch.ones((base_color.shape[0], 1), dtype=torch.float32) base_color = torch.cat((base_color, alpha_channel), dim=1) randomize_plan["base_color"] = base_color if metallic_range: metallic = sample_uniform( lower=torch.tensor(metallic_range[0], dtype=torch.float32), upper=torch.tensor(metallic_range[1], dtype=torch.float32), - size=(len(env_ids), 1), + size=(1 if self._shared else len(env_ids), 1), ) randomize_plan["metallic"] = metallic if roughness_range: roughness = sample_uniform( lower=torch.tensor(roughness_range[0], dtype=torch.float32), upper=torch.tensor(roughness_range[1], dtype=torch.float32), - size=(len(env_ids), 1), + size=(1 if self._shared else len(env_ids), 1), ) randomize_plan["roughness"] = roughness if ior_range: ior = sample_uniform( lower=torch.tensor(ior_range[0], dtype=torch.float32), upper=torch.tensor(ior_range[1], dtype=torch.float32), - size=(len(env_ids), 1), + size=(1 if self._shared else len(env_ids), 1), ) randomize_plan["ior"] = ior @@ -1222,7 +1259,9 @@ def _call_legacy( selected_env_ids = [int(env_idx) for env_idx in env_ids] if isinstance(self.entity, RigidObject): - self.entity.set_visual_material(self._legacy_mat, env_ids=selected_env_ids) + self.entity.set_visual_material( + self._legacy_mat, env_ids=selected_env_ids, shared=self._shared + ) self._mat_insts = self.entity.get_visual_material_inst( env_ids=selected_env_ids ) @@ -1231,13 +1270,16 @@ def _call_legacy( self._legacy_mat, env_ids=selected_env_ids, link_names=self.entity_cfg.link_names, + shared=self._shared, ) self._mat_insts = self.entity.get_visual_material_inst( env_ids=selected_env_ids, link_names=self.entity_cfg.link_names, ) - for i, data in enumerate(self._mat_insts): + for i, data in enumerate( + self._mat_insts[:1] if self._shared else self._mat_insts + ): if isinstance(self.entity, RigidObject): mat: VisualMaterialInst = data elif isinstance(self.entity, Articulation): diff --git a/embodichain/lab/scripts/preview_material.py b/embodichain/lab/scripts/preview_material.py new file mode 100644 index 0000000000..cce6a101fc --- /dev/null +++ b/embodichain/lab/scripts/preview_material.py @@ -0,0 +1,740 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Preview default material presets in a native window or export reference images. + +Help and catalog queries do not initialize DexSim or download material assets. +Scene geometry, lighting and cameras belong to this command; the material factory +only loads material definitions. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import json +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +from time import perf_counter +import traceback +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from embodichain.lab.sim.material import VisualMaterialCfg + +__all__ = ["main", "cli"] + + +def _positive_integer(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return number + + +def _material_params(value: str) -> dict[str, Any]: + try: + params = json.loads(value) + except ValueError as error: + raise argparse.ArgumentTypeError( + "--material-params must be a JSON object" + ) from error + if not isinstance(params, dict): + raise argparse.ArgumentTypeError("--material-params must be a JSON object") + if {"uid", "preset"} & params.keys(): + raise argparse.ArgumentTypeError( + "Select the preset with --material; preview UIDs are command-owned" + ) + return params + + +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="embodichain preview-material", + description=__doc__, + ) + selection = parser.add_mutually_exclusive_group() + selection.add_argument( + "--list", + action="store_true", + help="Print offline catalog metadata as JSON lines and exit.", + ) + selection.add_argument( + "--all", + action="store_true", + help="Export all selected presets without opening a window.", + ) + selection.add_argument( + "--material", help="Exact preset name; defaults to GlassClear." + ) + parser.add_argument( + "--source", + choices=("all", "builtin", "resource"), + default="all", + help="Filter --list or --all by material source.", + ) + parser.add_argument( + "--renderer", + choices=("rt", "hybrid", "fast-rt"), + default="rt", + help="Native renderer; rt (OfflineRT) supports the full catalog.", + ) + parser.add_argument( + "--material-params", + type=_material_params, + default={}, + help="VisualMaterialCfg overrides as JSON; advanced fields belong inside parameters.", + ) + parser.add_argument( + "--compatible-only", + action="store_true", + help="With --all, explicitly skip incompatible presets and report their names.", + ) + parser.add_argument( + "--headless", + action=argparse.BooleanOptionalAction, + default=False, + help="Export images and exit without opening a native window.", + ) + parser.add_argument( + "--max-steps", + type=_positive_integer, + help="Bound native-window preview updates; otherwise exit with Ctrl+C.", + ) + parser.add_argument( + "--gpu-id", + "--gpu_id", + type=int, + default=0, + help="GPU index used by DexSim rendering.", + ) + parser.add_argument( + "--compare-native", + action="store_true", + help="Compare unmodified presets with direct native loading and fail on a mismatch.", + ) + parser.add_argument( + "--gallery", + action="store_true", + help="Export individual WebP images and grouped galleries (Pillow and matplotlib required).", + ) + parser.add_argument( + "--model", + type=Path, + help="Optional segmented skin face GLB; not part of the material package.", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("outputs/default-materials"), + help="Directory for PNG captures, results.json and optional WebP galleries.", + ) + parser.add_argument( + "--size", + type=_positive_integer, + default=320, + help="Square capture resolution in pixels.", + ) + parser.add_argument( + "--frames", + type=_positive_integer, + default=4, + help="Accumulation frames per capture.", + ) + parser.add_argument( + "--spp", + type=_positive_integer, + default=16, + help="DexSim render iterations per frame.", + ) + return parser + + +def _select_materials( + args: argparse.Namespace, +) -> tuple[list[VisualMaterialCfg], list[dict[str, str]]]: + """Resolve configurations and renderer requirements before constructing a World.""" + from embodichain._material_catalog import ( + get_default_material_info, + list_default_materials, + ) + from embodichain.lab.sim import VisualMaterialCfg + from embodichain.lab.sim._material_presets import ( + _asset_desc_from_cfg, + _validate_backend, + ) + + if min(args.size, args.frames, args.spp) < 1: + raise ValueError("size, frames and spp must be positive") + if args.gpu_id < 0: + raise ValueError("gpu-id must be nonnegative") + if args.compatible_only and not args.all: + raise ValueError("--compatible-only requires --all") + if args.source != "all" and not args.all: + raise ValueError("--source requires --list or --all") + if args.max_steps is not None and (args.headless or args.all): + raise ValueError( + "--max-steps applies to native-window previews; remove --headless/--all" + ) + if args.compare_native and args.material_params: + raise ValueError( + "--compare-native compares authored presets; remove --material-params" + ) + names = ( + [ + name + for name in list_default_materials() + if args.source == "all" + or get_default_material_info(name)["source"] == args.source + ] + if args.all + else [args.material or "GlassClear"] + ) + if args.model is not None: + args.model = args.model.expanduser().resolve() + if "SkinHumanLight" not in names: + raise ValueError("--model requires SkinHumanLight or a batch containing it") + if not args.model.is_file(): + raise FileNotFoundError(args.model) + configs, skipped = [], [] + for name in names: + cfg = VisualMaterialCfg.from_preset( + name, uid=f"preset_{name}", **args.material_params + ) + desc = _asset_desc_from_cfg(cfg, cfg.uid) + try: + _validate_backend(desc, args.renderer) + except ValueError as error: + if not args.compatible_only: + raise ValueError( + f"{error} Use --all --compatible-only to explicitly select supported presets." + ) from error + skipped.append({"name": name, "reason": str(error)}) + else: + configs.append(cfg) + if not configs: + raise ValueError( + "No selected presets support this renderer and parameter combination" + ) + return configs, skipped + + +def _write_galleries(output: Path, rows: list[dict[str, Any]], renderer: str) -> None: + """Export captured images without changing their rendering settings. + + Args: + output: Directory containing the preset PNG captures. + rows: Material records from the completed rendering run. + renderer: Renderer name shown in gallery captions. + """ + import math + from importlib.metadata import version + import matplotlib + from PIL import Image + from embodichain.data import get_default_material_info + + matplotlib.use("Agg") + from matplotlib import pyplot as plt + + for row in rows: + with Image.open(output / f"{row['name']}_preset.png") as capture: + capture.save(output / f"{row['name']}.webp", quality=92) + groups = { + "default-materials": (rows, 6), + "builtin-materials": ([row for row in rows if row["source"] == "builtin"], 3), + "surface-materials": ( + [ + row + for row in rows + if row["source"] == "resource" + and len(get_default_material_info(row["name"])["renderers"]) > 1 + ], + 5, + ), + "advanced-materials": ( + [ + row + for row in rows + if row["source"] == "resource" + and get_default_material_info(row["name"])["renderers"] == ["rt"] + ], + 4, + ), + } + for filename, (records, columns) in groups.items(): + if not records: + continue + columns = min(columns, len(records)) + count_rows = math.ceil(len(records) / columns) + fig, axes = plt.subplots( + count_rows, + columns, + figsize=(3.2 * columns, 3.5 * count_rows), + squeeze=False, + facecolor="#171b20", + ) + for axis in axes.flat: + axis.axis("off") + for axis, row in zip(axes.flat, records): + with Image.open(output / f"{row['name']}_preset.png") as capture: + axis.imshow(capture.copy()) + axis.set_title(row["name"], fontsize=11, color="white", pad=6) + renderer_label = {"rt": "OfflineRT", "hybrid": "Hybrid", "fast-rt": "FastRT"}[ + renderer + ] + fig.suptitle( + f"Default materials · DexSim {version('dexsim-engine')} · {renderer_label}", + color="white", + fontsize=15, + ) + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig( + output / f"{filename}.webp", + dpi=160, + facecolor=fig.get_facecolor(), + pil_kwargs={"quality": 90}, + ) + plt.close(fig) + + +def _render_materials( + args: argparse.Namespace, + configs: list[VisualMaterialCfg], + skipped: list[dict[str, str]], +) -> list[dict[str, Any]]: + """Capture one fixed studio scene, optionally retaining its native viewer.""" + from embodichain.data import get_default_material_info + import cv2 + import numpy as np + import dexsim.render as render + from importlib.metadata import version + from embodichain.lab.sim import ( + SimulationManager, + SimulationManagerCfg, + VisualMaterialCfg, + ) + from embodichain.lab.sim.cfg import DenoisingCfg, RenderCfg + from embodichain.data.assets.materials import DefaultMaterials + + args.output.mkdir(parents=True, exist_ok=True) + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + gpu_id=args.gpu_id, + device="cpu", + scene_node_capacity=256, + startup_summary="off", + render_cfg=RenderCfg( + renderer=args.renderer, + spp=args.spp, + min_bounces=3, + max_bounces=14, + tone_mapping_enabled=True, + tone_mapping_exposure=0.9, + denoising=DenoisingCfg(window="optix", offscreen="optix"), + ), + ) + ) + world, env = sim.get_world(), sim.get_env() + camera = sample = inner = native = material = None + reference_files = None + rows: list[dict[str, Any]] = [] + try: + sim.enable_physics(False) + sim.set_emission_light([0.85, 0.9, 1.0], 24.0) + # Independent, fixed scene: colored cards reveal transmission, broad + # rectangular lights reveal roughness and normal-map direction. + for index, (x, color) in enumerate( + ( + (-0.85, [0.8, 0.06, 0.02, 1]), + (0.0, [0.8, 0.8, 0.8, 1]), + (0.85, [0.02, 0.18, 0.8, 1]), + ) + ): + card = env.create_cube(0.7, 0.04, 1.9) + card.set_name(f"preview_card_{index}") + card.set_location(x, 1.1, 0.95) + card_material = sim.create_visual_material( + VisualMaterialCfg(uid=f"card_{index}", base_color=color, roughness=0.6) + ) + card.set_material(card_material.inst.mat) + if index == 1: + yy, xx = np.indices((256, 256)) + checks = ((xx // 32 + yy // 32) % 2)[..., None] + pixels = np.where(checks, [225, 225, 225], [45, 45, 45]).astype( + np.uint8 + ) + texture = env.create_color_texture(pixels, has_alpha=False) + card_material.inst.set_base_color_texture(texture_obj=texture) + for name, location, direction, color, power in ( + ("key", (-2, -2, 3), (2, 2, -2), (1, 0.85, 0.7), 90), + ("fill", (2, 0, 3), (-2, 0, -2), (0.6, 0.8, 1), 60), + ): + light = env.create_light(name, render.LightType.RECT) + light.set_location(*location) + light.set_direction(*direction) + light.set_color(*color) + light.set_intensity(power) + light.set_rect_wh(1.8, 1.1) + camera = env.create_camera( + "material_camera", args.size, args.size, True, render.ViewFlags.COLOR + ) + camera.set_near(0.02) + camera.set_far(20) + focal = args.size * 1.4 + camera.set_intrinsic( + np.array( + [[focal, 0, args.size / 2], [0, focal, args.size / 2], [0, 0, 1]], + dtype=np.float32, + ) + ) + camera.look_at([1.5, -3.8, 1.8], [0, 0, 0.8], [0, 0, 1]) + camera.open_camera() + source = None + reference_files = ( + TemporaryDirectory(prefix="ec-material-reference-") + if args.compare_native + else None + ) + for cfg in configs: + name = cfg.preset + info = get_default_material_info(name) + started = perf_counter() + material = sim.create_visual_material(cfg) + load_ms = (perf_counter() - started) * 1000 + if sample is not None: + env.remove_actor(sample) + if inner is not None: + env.remove_actor(inner) + inner = None + skin_segments = None + if name == "SkinHumanLight" and args.model: + if not args.model.is_file(): + raise FileNotFoundError(args.model) + sample = env.load_actor(str(args.model.resolve()), False, False) + env.attach(sample.node) + world.update(0.0) + body = sample.get_render_body() + skin_segments = [ + i + for i in range(body.get_mesh_count()) + if "skin" in body.get_segment_name(i).lower() + ] + if ( + not skin_segments + and body.get_mesh_count() == 8 + and all(not body.get_segment_name(i) for i in range(8)) + ): + skin_segments = [0, 1] # Explicit SkinFaceDemo GLB-order fallback. + if not skin_segments: + raise ValueError("The supplied face model has no Skin_* segments.") + camera.look_at([0.19, -0.69, 0.165], [0, 0, 0.10], [0, 0, 1]) + else: + category = get_default_material_info(name)["category"] + sample = ( + env.create_cube(1.3, 0.16, 1.3) + if category in {"architecture", "wood", "stone", "ground"} + else env.create_sphere(0.66, 96, 3, 2) + ) + sample.set_location(0, 0, 0.8) + sample.set_name(f"preview_{name}") + camera.look_at([1.5, -3.8, 1.8], [0, 0, 0.8], [0, 0, 1]) + if name == "SoapBubble": + # Geometry stays here: a smaller reversed-axis shell supplies + # the air-facing interface. UVs and nominal sizes are untouched. + inner = env.create_sphere(0.66, 96, 3, 2) + inner.set_name("preview_inner_shell") + inner.set_location(0, 0, 0.8) + inner.set_scale(-0.975, 0.975, 0.975) + + def bind(instance: Any, inner_instance: Any | None = None) -> None: + if skin_segments is None: + sample.set_material(instance) + else: + for segment in skin_segments: + sample.get_render_body().set_mesh_material(segment, instance) + if inner is not None: + inner.set_material(inner_instance) + + def capture(label: str) -> Any: + for _ in range(args.frames): + world.update(0.0) + camera.render(render.ViewFlags.COLOR, render.ViewFlags.COLOR, False) + rgb = np.ascontiguousarray(camera.get_rgb_map()[..., :3]) + saved = cv2.imwrite( + str(args.output / f"{name}_{label}.png"), + cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), + ) + if not saved: + raise RuntimeError( + f"Cannot save {name}_{label}.png to {args.output}." + ) + return rgb + + inner_material = ( + sim.create_visual_material( + VisualMaterialCfg.from_preset( + "SoapBubbleInterior", uid="bubble_inner" + ) + ) + if inner is not None + else None + ) + bind(material.inst.mat, inner_material.inst.mat if inner_material else None) + packaged = capture("preset") + row: dict[str, Any] = { + "name": name, + "load_ms": load_ms, + "ior": material.inst.ior, + "roughness": material.inst.roughness, + "metallic": material.inst.mat.get_metallic(), + "transmission": material.inst.mat.get_transmission(), + "base_color": material.inst.base_color, + "native_match": None, + "source": info["source"], + "revision": info["revision"], + "config": cfg.to_dict(), + "geometry": ( + "segmented-face" + if skin_segments is not None + else ( + "double-shell" + if inner is not None + else ( + "slab" + if info["category"] + in {"architecture", "wood", "stone", "ground"} + else "sphere" + ) + ) + ), + } + if args.compare_native: + if info["source"] == "builtin": + reference_path = Path(reference_files.name) / name + reference_path.mkdir() + # Direct native baseline uses the authored definition, + # without the runtime resolver or Python default setters. + (reference_path / "material.json").write_text( + json.dumps( + { + "schema_version": 1, + "parameters": info["parameters"], + "textures": {}, + } + ), + encoding="utf-8", + ) + else: + if source is None: + source = DefaultMaterials() + reference_path = Path(source.get_material_path(name)) + native = env.load_material(str(reference_path), f"reference_{name}") + native_inner = ( + env.load_material( + source.get_material_path("SoapBubbleInterior"), + "reference_inner", + ) + if inner is not None + else None + ) + bind( + native.get_inst(), native_inner.get_inst() if native_inner else None + ) + reference = capture("native") + repeat = capture("native_repeat") + crop = np.s_[ + args.size // 4 : 3 * args.size // 4, + args.size // 4 : 3 * args.size // 4, + :, + ] + difference = float( + np.sqrt( + np.mean( + ( + packaged[crop].astype(float) + - reference[crop].astype(float) + ) + ** 2 + ) + ) + ) + noise = float( + np.sqrt( + np.mean( + (repeat[crop].astype(float) - reference[crop].astype(float)) + ** 2 + ) + ) + ) + row.update( + rmse=difference, + native_repeat_rmse=noise, + threshold=max(3.0, 3 * noise + 0.5), + native_match=difference <= max(3.0, 3 * noise + 0.5), + ) + for getter in ( + "get_ior", + "get_roughness", + "get_metallic", + "get_transmission", + "get_base_color", + ): + actual, expected = ( + getattr(material.inst.mat, getter)(), + getattr(native.get_inst(), getter)(), + ) + matches = ( + expected is actual + if expected is None or actual is None + else np.allclose(actual, expected, atol=1e-5) + ) + if not matches: + raise AssertionError((name, getter, actual, expected)) + if not row["native_match"]: + raise AssertionError(row) + bind(material.inst.mat, inner_material.inst.mat if inner_material else None) + rows.append(row) + print(json.dumps(row), flush=True) + report = { + "schema_version": 1, + "scene": "material-studio-v1", + "renderer": args.renderer, + "dexsim": version("dexsim-engine"), + "size": args.size, + "frames": args.frames, + "spp": args.spp, + "materials": rows, + "skipped_materials": skipped, + "model": str(args.model) if args.model is not None else None, + } + (args.output / "results.json").write_text( + json.dumps(report, indent=2) + "\n", encoding="utf-8" + ) + if not args.headless and not args.all: + from embodichain.lab.scripts._preview import _run_preview_loop + + sim.prepare() + if not sim.open_window(): + raise RuntimeError( + "The native material-preview window could not be opened" + ) + eye, target = ( + ([0.19, -0.69, 0.165], [0, 0, 0.10]) + if args.model is not None + else ([1.5, -3.8, 1.8], [0, 0, 0.8]) + ) + world.get_windows().set_look_at(eye, target, [0, 0, 1]) + print( + "Material preview ready. Press Ctrl+C to exit.", + file=sys.stderr, + flush=True, + ) + _run_preview_loop(sim, None, interactive=True, max_steps=args.max_steps) + finally: + try: + if camera is not None: + camera.close_camera() + finally: + try: + sim.destroy(exit_process=False) + finally: + if reference_files is not None: + reference_files.cleanup() + return rows + + +def main(args: argparse.Namespace) -> None: + """Query or preview default materials using the production preset resolver. + + Args: + args: Parsed material-preview arguments. Batch mode always exports + without a window; single previews use one unless headless is set. + + Raises: + ValueError: Selection, override or renderer requirements are invalid. + OSError: A supplied model or output directory cannot be accessed. + RuntimeError: Native rendering or window creation fails. + AssertionError: A requested direct-native comparison fails. + """ + from embodichain._material_catalog import ( + get_default_material_info, + list_default_materials, + ) + + if args.list: + for name in list_default_materials(): + info = get_default_material_info(name) + if args.source == "all" or info["source"] == args.source: + print(json.dumps(info)) + return + configs, skipped = _select_materials(args) + for item in skipped: + print(f"Skipping {item['name']}: {item['reason']}", file=sys.stderr) + from embodichain.lab.sim import SimulationManager + + failure: BaseException | None = None + try: + rows = _render_materials(args, configs, skipped) + except BaseException as error: + # Native views in unwound frames must be released before deferred World + # destruction. Preserve the original error while clearing its views. + pending = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + traceback.clear_frames(current.__traceback__) + pending.extend( + cause + for cause in (current.__cause__, current.__context__) + if cause is not None + ) + failure = error.with_traceback(None) + try: + SimulationManager.flush_cleanup_queue() + except BaseException as error: + if failure is None: + raise + print( + f"Deferred preview cleanup failed after {failure!r}: {error!r}", + file=sys.stderr, + ) + if failure is not None: + raise failure + if args.gallery: + _write_galleries(args.output, rows, args.renderer) + + +def cli(argv: Sequence[str] | None = None) -> None: + """Run the material-preview command. + + Args: + argv: Arguments excluding the command name, or None for sys.argv. + """ + parser = _create_parser() + args = parser.parse_args(argv) + try: + main(args) + except (ValueError, TypeError, OSError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + cli() diff --git a/embodichain/lab/sim/_material_presets.py b/embodichain/lab/sim/_material_presets.py new file mode 100644 index 0000000000..feaed7b053 --- /dev/null +++ b/embodichain/lab/sim/_material_presets.py @@ -0,0 +1,404 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Validated native material definitions and World-owned resource lifetimes.""" + +from __future__ import annotations + +import copy +import json +import math +from collections.abc import Sequence +from dataclasses import dataclass +from hashlib import sha256 +from numbers import Real +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, TYPE_CHECKING +from uuid import uuid4 + +from embodichain.data.default_materials import get_default_material_info + +if TYPE_CHECKING: + from .material import VisualMaterialCfg + +__all__: list[str] = [] + +_FLAT_PARAMETERS = { + "base_color": "base_color", + "metallic": "metallic", + "roughness": "roughness", + "emissive": "emissive", + "emissive_intensity": "emission_intensity", + "ior": "ior", +} +_TEXTURE_FIELDS = { + "base_color_texture": "base_color", + "metallic_texture": "metallic", + "roughness_texture": "roughness", + "normal_texture": "normal", + "ao_texture": "ambient_occlusion", +} +_FRACTIONS = { + "metallic", + "roughness", + "reflectance", + "ambient_occlusion", + "transmission", + "anisotropy_strength", + "clearcoat", + "clearcoat_roughness", + "sheen_roughness", + "specular_strength", + "diffuse_transmission_strength", + "iridescence_factor", + "iridescence_min_reflection", +} +_POSITIVE = {"ior", "volume_attenuation_distance", "iridescence_ior"} +_NONNEGATIVE = { + "emission_intensity", + "volume_thickness", + "iridescence_reflection_boost", +} +_VECTORS = { + "base_color": (3, 4), + "emissive": (3,), + "sheen_color": (3,), + "specular_color": (3,), + "diffuse_transmission_color": (3,), + "volume_attenuation_color": (3,), + "iridescence_thickness": (2,), +} +_BOOLEANS = {"thin_walled", "double_sided"} +_BASIC_PARAMETERS = { + "base_color", + "metallic", + "roughness", + "reflectance", + "ambient_occlusion", + "ior", + "emissive", + "emission_intensity", + "double_sided", +} +_TEXTURE_KEYS = { + "base_color", + "ambient_occlusion", + "roughness", + "metallic", + "metallic_roughness", + "normal", + "emissive", + "anisotropy", + "transmission", + "clearcoat", + "clearcoat_roughness", + "clearcoat_normal", + "sheen_color", + "sheen_roughness", + "iridescence", + "iridescence_thickness", + "specular_color", + "specular_strength", + "diffuse_transmission_color", + "diffuse_transmission_strength", + "volume_attenuation_strength", +} + + +def _number(value: Any, key: str) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, Real) + or not math.isfinite(float(value)) + ): + raise ValueError(f"Visual material parameter {key!r} must be a finite number.") + return float(value) + + +def _normalize_parameters(parameters: dict[str, Any]) -> dict[str, Any]: + if not isinstance(parameters, dict): + raise ValueError("Visual material parameters must be a dictionary.") + normalized = {} + for supplied_key, value in parameters.items(): + key = ( + "emission_intensity" + if supplied_key == "emissive_intensity" + else supplied_key + ) + if key in normalized: + raise ValueError(f"Duplicate visual material parameter {key!r}.") + if key in _BOOLEANS: + if not isinstance(value, bool): + raise ValueError( + f"Visual material parameter {key!r} must be a boolean." + ) + normalized[key] = value + elif key in _VECTORS: + if ( + not isinstance(value, Sequence) + or isinstance(value, (str, bytes)) + or len(value) not in _VECTORS[key] + ): + raise ValueError(f"Invalid visual material vector {key!r}.") + vector = [_number(component, key) for component in value] + if any(component < 0 for component in vector) or ( + key == "base_color" and len(vector) == 4 and vector[3] > 1 + ): + raise ValueError(f"Invalid visual material vector {key!r}.") + if key == "iridescence_thickness" and vector[0] > vector[1]: + raise ValueError( + "iridescence_thickness must be ordered from minimum to maximum." + ) + normalized[key] = vector + elif key in _FRACTIONS | _POSITIVE | _NONNEGATIVE | {"anisotropy_rotation"}: + scalar = _number(value, key) + if ( + (key in _FRACTIONS and not 0 <= scalar <= 1) + or (key in _POSITIVE and scalar <= 0) + or (key in _NONNEGATIVE and scalar < 0) + ): + raise ValueError( + f"Invalid visual material parameter {key!r}: {value!r}." + ) + normalized[key] = scalar + else: + raise ValueError(f"Unknown visual material parameter {supplied_key!r}.") + return normalized + + +def _canonical_cfg_parameters(cfg: VisualMaterialCfg) -> dict[str, Any]: + """Keep common overrides in their flat fields, including native aliases. + + This makes dataclasses.replace(ior=None) clear an override regardless of + whether it originally came from Python keywords or a parameters mapping. + """ + parameters = _normalize_parameters(cfg.parameters) + for field_name, key in _FLAT_PARAMETERS.items(): + if key in parameters: + if getattr(cfg, field_name) is not None: + raise ValueError( + f"Duplicate visual material parameter {key!r} in flat fields and parameters." + ) + setattr(cfg, field_name, parameters.pop(key)) + return parameters + + +def _cfg_parameters(cfg: VisualMaterialCfg) -> dict[str, Any]: + parameters = _normalize_parameters(cfg.parameters) + for field_name, key in _FLAT_PARAMETERS.items(): + value = getattr(cfg, field_name) + if value is not None: + if key in parameters: + raise ValueError( + f"Duplicate visual material parameter {key!r} in flat fields and parameters." + ) + parameters[key] = value + return _normalize_parameters(parameters) + + +@dataclass +class _AssetMaterialDesc: + # Keep identity independent of download roots, temporary paths and native + # handles. Names are mutable only for anonymous Spawn declaration naming. + name: str + preset: str | None + parameters_json: str + revision: str + textures: tuple[tuple[str, str], ...] = () + + def template_key(self) -> str: + definition = ( + self.revision, + self.preset, + self.parameters_json, + self.textures, + ) + return sha256(repr(definition).encode()).hexdigest() + + +def _asset_desc_from_cfg(cfg: VisualMaterialCfg, uid: str) -> _AssetMaterialDesc: + if cfg.preset is not None: + info = get_default_material_info(cfg.preset) + if any(getattr(cfg, key) is not None for key in _TEXTURE_FIELDS): + raise ValueError("Preset texture replacement or removal is not supported.") + revision = info["revision"] + else: + revision = "native-manifest-v1" + parameters = _cfg_parameters(cfg) + return _AssetMaterialDesc( + name=uid, + preset=cfg.preset, + parameters_json=json.dumps( + parameters, sort_keys=True, separators=(",", ":"), allow_nan=False + ), + revision=revision, + textures=tuple( + (key, str(getattr(cfg, field_name))) + for field_name, key in _TEXTURE_FIELDS.items() + if getattr(cfg, field_name) is not None + ), + ) + + +def _validate_backend(desc: _AssetMaterialDesc, renderer: str) -> None: + # NoRender stores the full declaration; no native resource is requested. + if renderer == "no-render": + return + supported = ( + get_default_material_info(desc.preset)["renderers"] + if desc.preset + else ["rt", "hybrid", "fast-rt"] + ) + advanced = set(json.loads(desc.parameters_json)) - _BASIC_PARAMETERS + if renderer not in supported or (advanced and renderer != "rt"): + raise ValueError( + f"Visual material {desc.preset or desc.name!r} requires renderer='rt' (OfflineRT); current renderer is {renderer!r}." + ) + + +def _legacy_manifest(directory: Path) -> dict[str, Any]: + # Match the native suffix discovery, including its alphabetical precedence + # for Metalness over Metallic. Displacement remains authoring-only. + suffixes = { + "AmbientOcclusion": "ambient_occlusion", + "Color": "base_color", + "Metallic": "metallic", + "MetallicRoughness": "metallic_roughness", + "Metalness": "metallic", + "Normal": "normal", + "Roughness": "roughness", + } + textures = {} + for suffix, key in suffixes.items(): + matches = sorted(directory.glob(f"*_{suffix}.png")) + if matches: + textures[key] = str(matches[0].resolve()) + return {"schema_version": 1, "parameters": {}, "textures": textures} + + +class _MaterialResources: + """One World owns staged manifests, immutable templates and UID handles. + + Cost scales with distinct definitions and UIDs, never with frames or repeated + lookups. Only JSON is staged; source images stay in the shared data cache. + """ + + def __init__(self, world: Any, renderer: str) -> None: + self._world = world + self.renderer = renderer + self._directory: TemporaryDirectory[str] | None = None + self._dataset: Any | None = None + self._templates: dict[str, Any] = {} + self._instances: dict[str, tuple[Any, Any]] = {} + + def _manifest(self, desc: _AssetMaterialDesc) -> dict[str, Any]: + info = get_default_material_info(desc.preset) if desc.preset else None + if info is not None and info["source"] == "builtin": + directory = Path.cwd() + pack_root = None + manifest = { + "schema_version": 1, + "parameters": info["parameters"], + "textures": {}, + } + elif desc.preset: + if self._dataset is None: + from embodichain.data.assets.materials import DefaultMaterials + + self._dataset = DefaultMaterials() + directory = Path(self._dataset.get_material_path(desc.preset)) + source = directory / "material.json" + manifest = ( + json.loads(source.read_text(encoding="utf-8")) + if source.is_file() + else _legacy_manifest(directory) + ) + pack_root = directory.parent.resolve() + else: + directory = Path.cwd() + pack_root = None + manifest = { + "schema_version": 1, + "parameters": {}, + "textures": dict(desc.textures), + } + if manifest.get("schema_version") != 1: + raise ValueError( + "Only native material manifest schema_version=1 is supported." + ) + manifest = copy.deepcopy(manifest) + parameters = _normalize_parameters(manifest.get("parameters", {})) + parameters.update(json.loads(desc.parameters_json)) + manifest["parameters"] = _normalize_parameters(parameters) + textures = manifest.get("textures", {}) + if not isinstance(textures, dict) or set(textures) - _TEXTURE_KEYS: + raise ValueError("Invalid native material texture definitions.") + for key, value in textures.items(): + if not isinstance(value, str) or not value: + raise ValueError(f"Invalid native material texture {key!r}.") + path = (directory / value).resolve() + if pack_root is not None and not path.is_relative_to(pack_root): + raise ValueError( + f"Material texture escapes its resource pack: {value!r}." + ) + if not path.is_file(): + raise FileNotFoundError(f"Missing material texture: {path}.") + textures[key] = str(path) + return manifest + + def resolve(self, desc: _AssetMaterialDesc) -> tuple[Any, Any]: + _validate_backend(desc, self.renderer) + if self.renderer == "no-render": + raise RuntimeError("Native material creation requires a renderer.") + if desc.name in self._instances: + return self._instances[desc.name] + key = desc.template_key() + if key not in self._templates: + manifest = self._manifest(desc) + if self._directory is None: + self._directory = TemporaryDirectory(prefix="ec-materials-") + directory = Path(self._directory.name) / key + directory.mkdir(exist_ok=True) + (directory / "material.json").write_text( + json.dumps(manifest, indent=2, allow_nan=False), encoding="utf-8" + ) + if len(str(directory).encode()) > 255: + raise ValueError( + "Native material directory exceeds the 255-byte path limit." + ) + template = self._world.get_env().load_material( + str(directory), f"ec_material_{uuid4().hex}" + ) + if template is None: + raise RuntimeError( + f"DexSim could not load material {desc.preset or desc.name!r}." + ) + self._templates[key] = template + template = self._templates[key] + instance = template.get_inst(f"ec_instance_{uuid4().hex}") + result = (template, instance) + self._instances[desc.name] = result + return result + + def close(self) -> None: + self._instances.clear() + self._templates.clear() + self._dataset = None + if self._directory is not None: + self._directory.cleanup() + self._directory = None + self._world = None diff --git a/embodichain/lab/sim/material.py b/embodichain/lab/sim/material.py index d88c0410fe..0670a8da6c 100644 --- a/embodichain/lab/sim/material.py +++ b/embodichain/lab/sim/material.py @@ -17,17 +17,23 @@ from __future__ import annotations import copy +import math import torch import dexsim import numpy as np from dataclasses import dataclass -from typing import Dict, Union -from functools import cached_property +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any +from uuid import uuid4 from dexsim.engine import MaterialInst, Material from embodichain.utils import configclass, logger +if TYPE_CHECKING: + from dexsim.spawn import MaterialDesc + from ._material_presets import _AssetMaterialDesc + __all__ = [ "VisualMaterialCfg", "VisualMaterial", @@ -38,60 +44,215 @@ @configclass class VisualMaterialCfg: - """Configuration for visual material with PBR properties for rasterization and ray tracing.""" + """Manual PBR properties or explicit overrides of a default material preset. + + With ``preset=None``, unspecified common fields receive the historical + defaults at construction. With a preset, ``None`` inherits the authored + resource value. Serialization retains that distinction. Use + :meth:`from_preset` to construct a new preset configuration; ``replace`` + preserves existing fields rather than switching configuration sources. + Native scattering is selected by optical parameters, including + ``parameters={"transmission": 1.0}`` for a transmitting material. Explicit + BRDF/BTDF/BSDF model selection is not part of this configuration. + """ - uid: str = "default_mat" + uid: str | None = None + """Explicit shared identity, or None for an automatically named material.""" # Basic PBR properties - base_color: list = [0.5, 0.5, 0.5, 1.0] + base_color: list[float] | None = None """Base color/diffuse color (RGBA)""" - metallic: float = 0.0 + metallic: float | None = None """Metallic factor (0.0 = dielectric, 1.0 = metallic)""" - roughness: float = 0.7 + roughness: float | None = None """Surface roughness (0.0 = smooth, 1.0 = rough)""" # Additional PBR properties - emissive: list = [0.0, 0.0, 0.0] # Emissive color (RGB) - emissive_intensity: float = 1.0 # Emissive intensity multiplier + emissive: list[float] | None = None # Emissive color (RGB) + emissive_intensity: float | None = None # Emissive intensity multiplier # Texture maps - base_color_texture: str = None + base_color_texture: str | None = None """Base color texture map""" - metallic_texture: str = None + metallic_texture: str | None = None """Metallic map""" - roughness_texture: str = None + roughness_texture: str | None = None """Roughness map""" - normal_texture: str = None + normal_texture: str | None = None """Normal map""" - ao_texture: str = None + ao_texture: str | None = None """Ambient occlusion map""" # Ray tracing specific properties - ior: float = 1.5 + ior: float | None = None """Index of refraction for PBR materials, only used in ray tracing.""" - material_type: str = "BRDF" - """Ray tracing material type. Options: 'BRDF', 'BTDF', 'BSDF'""" + preset: str | None = None + """Default material name. Presets select scattering from native parameters.""" + + parameters: dict[str, Any] = {} + """Native schema-v1 advanced parameters; cannot duplicate common fields.""" + + def __post_init__(self) -> None: + if self.preset is not None or self.parameters: + from ._material_presets import _canonical_cfg_parameters + + self.parameters = _canonical_cfg_parameters(self) + if self.preset is None: + defaults = { + "base_color": [0.5, 0.5, 0.5, 1.0], + "metallic": 0.0, + "roughness": 0.7, + "emissive": [0.0, 0.0, 0.0], + "emissive_intensity": 1.0, + "ior": 1.5, + } + for key, value in defaults.items(): + if getattr(self, key) is None: + setattr(self, key, copy.deepcopy(value)) + else: + from ._material_presets import _asset_desc_from_cfg + + _asset_desc_from_cfg(self, self.uid or "preset") + + @classmethod + def from_preset( + cls, + name: str, + *, + uid: str | None = None, + parameters: dict[str, Any] | None = None, + **properties: Any, + ) -> VisualMaterialCfg: + """Construct explicit overrides of an authored default material. + + Args: + name: Case-sensitive catalog name, or ``SoapBubbleInterior``. + uid: Optional shared identity within the simulation World. + parameters: Advanced native parameters, such as volume absorption. + **properties: Common fields such as ``ior`` or ``roughness``. + + Returns: + A configuration retaining ``None`` for inherited common fields. + + Raises: + ValueError: A preset, override, duplicate property or texture edit + is invalid. + TypeError: An unknown configuration field is supplied. + """ + from embodichain.data.default_materials import get_default_material_info - # Currently disabled properties - # subsurface: float = 0.0 # Subsurface scattering factor - # subsurface_color: list = [1.0, 1.0, 1.0] # Subsurface scattering color + get_default_material_info(name) + return cls( + preset=name, + uid=uid, + parameters={} if parameters is None else parameters, + **properties, + ) @classmethod - def from_dict(cls, cfg_dict: dict) -> VisualMaterialCfg: - base = cls() + def from_dict(cls, cfg_dict: dict[str, Any]) -> VisualMaterialCfg: + """Construct manual defaults or preset overrides from Python/YAML data. + + Args: + cfg_dict: Serialized configuration, including optional ``preset``. + + Returns: + Configuration with inheritance preserved before defaults are filled. + + Raises: + ValueError: The removed scattering selector or an unknown preset + field is supplied. Describe optical properties explicitly. + """ + from dataclasses import fields + + if "material_type" in cfg_dict: + raise ValueError( + "VisualMaterialCfg.material_type has been removed. Use material " + "parameters such as transmission, roughness, metallic and ior; " + "legacy BTDF/BSDF choices require explicit optical parameters." + ) + names = {item.name for item in fields(cls)} + values = {} for k, v in cfg_dict.items(): - if hasattr(base, k): - setattr(base, k, v) + if k in names: + values[k] = v else: + if cfg_dict.get("preset") is not None: + raise ValueError( + f"Unknown field {k!r} in preset VisualMaterialCfg." + ) logger.log_warning(f"Unknown field '{k}' in VisualMaterialCfg.") - return base + return cls(**values) + + +def _material_scalar( + value: float, name: str, *, lower: float = 0.0, upper: float | None = None +) -> float: + value = float(value) + if ( + not math.isfinite(value) + or value < lower + or (upper is not None and value > upper) + ): + raise ValueError(f"Visual material {name} has an invalid value: {value}.") + return value + + +def _material_color(value: Sequence[float], name: str, size: int) -> tuple[float, ...]: + if len(value) != size: + raise ValueError(f"Visual material {name} must have {size} components.") + return tuple(_material_scalar(v, name) for v in value) + + +def _material_desc_from_cfg( + cfg: VisualMaterialCfg, uid: str +) -> MaterialDesc | _AssetMaterialDesc: + """Normalize both declaration and runtime material creation at one boundary.""" + from dexsim.spawn import MaterialDesc + + if not isinstance(uid, str) or not uid.strip(): + raise ValueError("Visual material uid must be a nonempty string.") + if not isinstance(cfg.parameters, dict): + raise ValueError("Visual material parameters must be a dictionary.") + if cfg.preset is not None: + from ._material_presets import _asset_desc_from_cfg + + return _asset_desc_from_cfg(cfg, uid) + if cfg.parameters: + from ._material_presets import _asset_desc_from_cfg + + return _asset_desc_from_cfg(cfg, uid) + color = _material_color(cfg.base_color, "base_color (RGBA)", 4) + _material_scalar(color[3], "base_color alpha", upper=1.0) + emissive = _material_color(cfg.emissive, "emissive (RGB)", 3) + intensity = _material_scalar(cfg.emissive_intensity, "emissive_intensity") + ior = _material_scalar(cfg.ior, "ior") + if ior == 0: + raise ValueError("Visual material ior must be positive.") + return MaterialDesc( + name=uid, + base_color=color, + base_color_map=cfg.base_color_texture, + metallic=_material_scalar(cfg.metallic, "metallic", upper=1.0), + metallic_map=cfg.metallic_texture, + roughness=_material_scalar(cfg.roughness, "roughness", upper=1.0), + roughness_map=cfg.roughness_texture, + normal_map=cfg.normal_texture, + ao_map=cfg.ao_texture, + emissive=( + *(_material_scalar(v * intensity, "emissive") for v in emissive), + 1.0, + ), + ior=ior, + has_alpha=True, + ) class VisualMaterial: @@ -101,31 +262,48 @@ class VisualMaterial: It holds multiple material instances, which is used to assign to different objects in the environment. """ - RT_MATERIAL_TYPES = [ - "BRDF", - "BTDF", - "BSDF", - ] - - MAT_TYPE_MAPPING: Dict[str, str] = { - "BRDF": "BRDF_GGX_SMITH", - "BTDF": "BTDF_GGX_SMITH", - "BSDF": "BSDF_GGX_SMITH", - } - - def __init__(self, cfg: VisualMaterialCfg, mat: Material): - if cfg.material_type not in self.RT_MATERIAL_TYPES: - logger.log_error( - f"Invalid material_type '{cfg.material_type}'. " - f"Supported types: {self.RT_MATERIAL_TYPES}" - ) + def __init__( + self, + cfg: VisualMaterialCfg, + mat: Material, + *, + initialize_default: bool = True, + mat_inst: MaterialInst | None = None, + ) -> None: + """Create a template wrapper or borrow an already configured native one. - self.uid = cfg.uid + Args: + cfg: Defaults for newly created material instances. + mat: Native template owned by the simulation. + initialize_default: Apply configuration to the native default instance. + Disable for a known existing template to preserve runtime edits. + mat_inst: Exact child instance of an immutable native resource template. + """ + self.uid = cfg.uid or f"visual_material_{uuid4().hex}" + self._definition = _material_desc_from_cfg(cfg, self.uid) + self._uses_manifest = cfg.preset is not None or bool(cfg.parameters) self.cfg = copy.deepcopy(cfg) + self.cfg.uid = self.uid self._mat = mat - self._mat_inst_list: list[str] = [] - - self._default_mat_inst = self.create_instance(self.uid) + self._instances: dict[str, VisualMaterialInst] = {} + self._native_instance_aliases: dict[str, VisualMaterialInst] = {} + + # Spawn resolves a named template through its native default instance. + # Keep that entry configured too when runtime and declarations share a UID. + if mat_inst is None and self._uses_manifest: + mat_inst = self._mat.get_inst(f"ec_instance_{uuid4().hex}") + self._default_mat_inst = VisualMaterialInst( + self.uid, + self._mat, + mat_inst=mat_inst if mat_inst is not None else self._mat.get_inst(), + ) + if initialize_default and not self._uses_manifest: + self.set_default_properties(self._default_mat_inst, self.cfg) + self._instances[self.uid] = self._default_mat_inst + if self._uses_manifest: + self._native_instance_aliases[self._default_mat_inst.mat.get_name()] = ( + self._default_mat_inst + ) @property def mat(self) -> Material: @@ -138,11 +316,31 @@ def inst(self) -> VisualMaterialInst: def set_default_properties( self, mat_inst: VisualMaterialInst, cfg: VisualMaterialCfg ) -> None: - mat_inst.set_base_color(cfg.base_color) - mat_inst.set_metallic(cfg.metallic) - mat_inst.set_roughness(cfg.roughness) - mat_inst.set_emissive(cfg.emissive) - # mat_inst.set_emissive_intensity(self.cfg.emissive_intensity) # Unimplemented + """Explicitly apply validated configuration to an existing instance. + + Args: + mat_inst: Instance whose properties will be replaced. + cfg: Material properties to apply. + """ + desc = _material_desc_from_cfg(cfg, self.uid) + if self._uses_manifest: + if desc != self._definition: + raise ValueError( + "Resource material reset requires its original definition; create a variant before binding." + ) + self._mat.reset_inst(mat_inst.mat) + mat_inst._emissive_rgb = None + mat_inst._emissive_intensity = 1.0 + mat_inst.base_color_texture = mat_inst.mat.get_base_color_map() + mat_inst.metallic_texture = None + mat_inst.roughness_texture = None + mat_inst.normal_texture = None + mat_inst.ao_texture = None + return + mat_inst.set_base_color(desc.base_color) + mat_inst.set_metallic(desc.metallic) + mat_inst.set_roughness(desc.roughness) + mat_inst._set_emissive(cfg.emissive, cfg.emissive_intensity) mat_inst.set_base_color_texture(cfg.base_color_texture) mat_inst.set_metallic_texture(cfg.metallic_texture) @@ -150,8 +348,7 @@ def set_default_properties( mat_inst.set_normal_texture(cfg.normal_texture) mat_inst.set_ao_texture(cfg.ao_texture) - mat_inst.set_ior(cfg.ior) - mat_inst.mat.update_pbr_material_type(self.MAT_TYPE_MAPPING[cfg.material_type]) + mat_inst.set_ior(desc.ior) def create_instance(self, uid: str) -> VisualMaterialInst: """Create a new material instance from this material template. @@ -165,11 +362,22 @@ def create_instance(self, uid: str) -> VisualMaterialInst: Returns: VisualMaterialInst: The created material instance. """ - inst = VisualMaterialInst(uid, self._mat) - # TODO: Support change default properties for material. - # This will improve the instance creation efficiency. - self.set_default_properties(inst, self.cfg) - self._mat_inst_list.append(uid) + if uid in self._instances: + return self._instances[uid] + if self._uses_manifest: + # Distinct public templates may share a native immutable template. + # Keep child names private so their user-provided UIDs cannot alias. + inst = VisualMaterialInst( + uid, + self._mat, + mat_inst=self._mat.get_inst(f"ec_instance_{uuid4().hex}"), + ) + else: + inst = VisualMaterialInst(uid, self._mat) + self.set_default_properties(inst, self.cfg) + self._instances[uid] = inst + if self._uses_manifest: + self._native_instance_aliases[inst.mat.get_name()] = inst return inst def get_default_instance(self) -> VisualMaterialInst: @@ -189,9 +397,13 @@ def get_instance(self, uid: str) -> VisualMaterialInst: Returns: VisualMaterialInst: The material instance. """ - if uid not in self._mat_inst_list: + if uid in self._instances: + return self._instances[uid] + if uid in self._native_instance_aliases: + return self._native_instance_aliases[uid] + if uid not in self._instances: logger.log_error(f"Material instance with uid '{uid}' does not exist.") - return VisualMaterialInst(uid, self._mat) + return self._instances[uid] class VisualMaterialInst: @@ -205,21 +417,14 @@ def __init__( ) -> None: self.uid = uid self._mat = mat - self._mat_inst = mat_inst - - # Init properties with default values - self.base_color = [0.5, 0.5, 0.5, 1.0] - self.metallic = 0.0 - self.roughness = 0.5 - self.emissive = [0.0, 0.0, 0.0] - self.emissive_intensity = 1.0 - self.base_color_texture = None + self._mat_inst = mat_inst if mat_inst is not None else mat.get_inst(uid) + self._emissive_rgb: tuple[float, ...] | None = None + self._emissive_intensity = 1.0 + self.base_color_texture = self.mat.get_base_color_map() self.metallic_texture = None self.roughness_texture = None self.normal_texture = None self.ao_texture = None - self.ior = 1.5 - # self.subsurface = 0.0 @classmethod def from_existing(cls, mat_inst: MaterialInst) -> VisualMaterialInst: @@ -241,39 +446,71 @@ def from_existing(cls, mat_inst: MaterialInst) -> VisualMaterialInst: @property def mat(self) -> MaterialInst: - existing_inst = getattr(self, "_mat_inst", None) - if existing_inst is not None: - return existing_inst - return self._mat.get_inst(self.uid) + return self._mat_inst - def set_base_color(self, color: list) -> None: + @property + def base_color(self) -> list[float]: + """Current native display-sRGB color (RGB); DexSim exposes no alpha getter.""" + return [float(v) for v in self.mat.get_base_color()[:3]] + + @property + def metallic(self) -> float: + """Current native metallic factor.""" + return float(self.mat.get_metallic()) + + @property + def roughness(self) -> float: + """Current native roughness.""" + return float(self.mat.get_roughness()) + + @property + def ior(self) -> float: + """Current native index of refraction.""" + return float(self.mat.get_ior()) + + @property + def emissive(self) -> list[float]: + """Unscaled emissive RGB, including when intensity is zero.""" + if self._emissive_intensity == 0 and self._emissive_rgb is not None: + return list(self._emissive_rgb) + return [ + float(v) / self._emissive_intensity for v in self.mat.get_emissive()[:3] + ] + + @property + def emissive_intensity(self) -> float: + """Multiplier applied to emissive RGB by this wrapper.""" + return self._emissive_intensity + + def set_base_color(self, color: Sequence[float]) -> None: """Set base color/diffuse color.""" - self.base_color = color - self.mat.set_base_color(color) + color = _material_color(color, "base_color (RGBA)", 4) + _material_scalar(color[3], "base_color alpha", upper=1.0) + self.mat.set_base_color(list(color)) def set_metallic(self, metallic: float) -> None: """Set metallic factor.""" - self.metallic = metallic - inst = self._mat.get_inst(self.uid) - inst.set_metallic(metallic) + self.mat.set_metallic(_material_scalar(metallic, "metallic", upper=1.0)) def set_roughness(self, roughness: float) -> None: """Set surface roughness.""" - self.roughness = roughness - inst = self._mat.get_inst(self.uid) - inst.set_roughness(roughness) + self.mat.set_roughness(_material_scalar(roughness, "roughness", upper=1.0)) - def set_emissive(self, emissive: list) -> None: + def set_emissive(self, emissive: Sequence[float]) -> None: """Set emissive color.""" - self.emissive = emissive - value = np.zeros(4) - value[0:3] = emissive - inst = self._mat.get_inst(self.uid) - inst.set_emissive(value) + self._set_emissive(emissive, self._emissive_intensity) + + def _set_emissive(self, emissive: Sequence[float], intensity: float) -> None: + rgb = _material_color(emissive, "emissive (RGB)", 3) + intensity = _material_scalar(intensity, "emissive_intensity") + value = [_material_scalar(v * intensity, "emissive") for v in rgb] + self.mat.set_emissive(np.asarray([*value, 1.0])) + self._emissive_rgb = rgb + self._emissive_intensity = intensity def set_emissive_intensity(self, intensity: float) -> None: """Set emissive intensity multiplier.""" - logger.log_error("Unimplemented: set_emissive_intensity") + self._set_emissive(self.emissive, intensity) def set_base_color_texture( self, @@ -298,15 +535,15 @@ def set_base_color_texture( if texture_path is not None: self.base_color_texture = texture_path - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_base_color_map(texture_path) elif texture_obj is not None: self.base_color_texture = texture_obj - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_base_color_map(texture_obj) elif texture_data is not None: self.base_color_texture = texture_data - inst = self._mat.get_inst(self.uid) + inst = self.mat # TODO: Optimize texture creation method. world = dexsim.default_world() @@ -332,11 +569,11 @@ def set_metallic_texture( if texture_path is not None: self.metallic_texture = texture_path - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_metallic_map(texture_path) elif texture_data is not None: self.metallic_texture = texture_data - inst = self._mat.get_inst(self.uid) + inst = self.mat # TODO: Optimize texture creation method. world = dexsim.default_world() @@ -362,11 +599,11 @@ def set_roughness_texture( if texture_path is not None: self.roughness_texture = texture_path - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_roughness_map(texture_path) elif texture_data is not None: self.roughness_texture = texture_data - inst = self._mat.get_inst(self.uid) + inst = self.mat # TODO: Optimize texture creation method. world = dexsim.default_world() @@ -392,11 +629,11 @@ def set_normal_texture( if texture_path is not None: self.normal_texture = texture_path - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_normal_map(texture_path) elif texture_data is not None: self.normal_texture = texture_data - inst = self._mat.get_inst(self.uid) + inst = self.mat # TODO: Optimize texture creation method. world = dexsim.default_world() @@ -422,11 +659,11 @@ def set_ao_texture( if texture_path is not None: self.ao_texture = texture_path - inst = self._mat.get_inst(self.uid) + inst = self.mat inst.set_ao_map(texture_path) elif texture_data is not None: self.ao_texture = texture_data - inst = self._mat.get_inst(self.uid) + inst = self.mat # TODO: Optimize texture creation method. world = dexsim.default_world() @@ -439,9 +676,10 @@ def set_ao_texture( def set_ior(self, ior: float) -> None: """Set index of refraction.""" - self.ior = ior - inst = self._mat.get_inst(self.uid) - inst.set_pbr_param("ior", ior) + ior = _material_scalar(ior, "ior") + if ior == 0: + raise ValueError("Visual material ior must be positive.") + self.mat.set_pbr_param("ior", ior) @dataclass @@ -492,14 +730,7 @@ def _is_same_material_inst( return True if current_inst is None or original_inst is None: return False - current_template = current_inst.get_template() - original_template = original_inst.get_template() - return ( - current_inst.get_name() == original_inst.get_name() - and current_template is not None - and original_template is not None - and current_template.get_name() == original_template.get_name() - ) + return current_inst.get_material_id() == original_inst.get_material_id() def _set_render_material(render_body, mesh_id: int, mat_inst: MaterialInst) -> None: diff --git a/embodichain/lab/sim/motion/planners/curobo/curobo_yaml.py b/embodichain/lab/sim/motion/planners/curobo/curobo_yaml.py index a8132d11d7..d76602d50f 100644 --- a/embodichain/lab/sim/motion/planners/curobo/curobo_yaml.py +++ b/embodichain/lab/sim/motion/planners/curobo/curobo_yaml.py @@ -924,12 +924,13 @@ def _get_or_create_dexsim_material( if material is None: material = env.create_pbr_material(name) material_inst = material.get_inst() - material_inst.update_pbr_material_type("BSDF_GGX_SMITH") + # Transmission selects the native scattering lobe before actor binding. + # A fourth color component controls the diagnostic shell's opacity. + opacity = color[3] if len(color) == 4 else 0.45 + material_inst.set_transmission(1.0 - opacity) material_inst.set_pbr_param("baseColor", color[0], color[1], color[2]) material_inst.set_pbr_param("roughness", 0.02) material_inst.set_pbr_param("ior", 1.12) - material_inst.set_pbr_param("colorAbsorption", 0.005, 0.005, 0.005) - material_inst.set_pbr_param("scaleAbsorption", 0.01) return material return material diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py index a17c39fd65..902e3296e2 100644 --- a/embodichain/lab/sim/objects/articulation.py +++ b/embodichain/lab/sim/objects/articulation.py @@ -38,6 +38,7 @@ from embodichain.lab.sim import VisualMaterialInst, VisualMaterial, ReuseSegmentState from embodichain.lab.sim.material import ( _capture_render_materials, + _is_same_material_inst, _restore_render_materials, _set_render_material, _wrap_first_render_material, @@ -3407,8 +3408,10 @@ def get_existing_visual_material( ) if working_inst is None: working_name = f"{self.uid}_reuse_{env_idx}_{link_name}" - template.create_inst(working_name) - working_inst = VisualMaterialInst(working_name, template) + native_inst = template.create_inst(working_name) + working_inst = VisualMaterialInst( + working_name, template, mat_inst=native_inst + ) segments.append( ReuseSegmentState( mesh_id=mesh_id, @@ -3438,6 +3441,14 @@ def apply_render_material_inst( _set_render_material( self._entities[env_idx].get_render_body(link_name), mesh_id, mat_inst ) + if mesh_id == 0: + current = self._visual_material[env_idx].get(link_name) + if current is None or not _is_same_material_inst(current.mat, mat_inst): + wrapped = _wrap_first_render_material([mat_inst]) + if wrapped is None: + self._visual_material[env_idx].pop(link_name, None) + else: + self._visual_material[env_idx][link_name] = wrapped def set_physical_visible( self, diff --git a/embodichain/lab/sim/objects/rigid_object.py b/embodichain/lab/sim/objects/rigid_object.py index 64ea84d4be..e56ca6abc1 100644 --- a/embodichain/lab/sim/objects/rigid_object.py +++ b/embodichain/lab/sim/objects/rigid_object.py @@ -72,6 +72,7 @@ ) from embodichain.lab.sim.material import ( _capture_render_materials, + _is_same_material_inst, _restore_render_materials, _set_render_material, _wrap_first_render_material, @@ -1585,8 +1586,10 @@ def get_existing_visual_material( ) if working_inst is None: working_name = f"{self.uid}_reuse_{env_idx}" - template.create_inst(working_name) - working_inst = VisualMaterialInst(working_name, template) + native_inst = template.create_inst(working_name) + working_inst = VisualMaterialInst( + working_name, template, mat_inst=native_inst + ) segments.append( ReuseSegmentState( mesh_id=mesh_id, @@ -1613,6 +1616,10 @@ def apply_render_material_inst( _set_render_material( self._entities[env_idx].get_render_body(), mesh_id, mat_inst ) + if mesh_id == 0: + current = self._visual_material[env_idx] + if current is None or not _is_same_material_inst(current.mat, mat_inst): + self._visual_material[env_idx] = _wrap_first_render_material([mat_inst]) def share_visual_material_inst(self, mat_insts: List[VisualMaterialInst]) -> None: """Share material instances for the rigid object. diff --git a/embodichain/lab/sim/objects/rigid_object_group.py b/embodichain/lab/sim/objects/rigid_object_group.py index 16eb3ac3dd..5d17b13749 100644 --- a/embodichain/lab/sim/objects/rigid_object_group.py +++ b/embodichain/lab/sim/objects/rigid_object_group.py @@ -648,11 +648,22 @@ def set_visual_material( self, mat: VisualMaterial, env_ids: Sequence[int] | None = None, + *, + reset_properties: bool = False, ) -> None: - """Assign one material instance to all members in each selected env.""" + """Assign one material instance to all members in each selected env. + + Args: + mat: Material template to assign. + env_ids: Environments to change, or all environments. + reset_properties: Explicitly restore template properties on selected + instances, as required by deterministic material reset events. + """ env, _, _ = self._selected_indices(env_ids) for env_id in env: material = mat.create_instance(f"{mat.uid}_{self.uid}_{env_id}") + if reset_properties: + mat.set_default_properties(material, mat.cfg) for entity in self._entities[env_id]: entity.set_material(material.mat) diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index 2e0fcbb207..6a4eb01ce3 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -29,6 +29,7 @@ import warp as wp from pathlib import Path +from uuid import uuid4 from copy import deepcopy from datetime import datetime from functools import cached_property, partial @@ -134,6 +135,7 @@ def _is_usd_path(path: object | None) -> bool: ) from embodichain.lab.sim.spawn.scene import SpawnScene from embodichain.lab.sim import VisualMaterial, VisualMaterialCfg +from embodichain.lab.sim.material import _material_desc_from_cfg from embodichain.lab.sim.profiler import Profiler, ProfilerCfg from embodichain.lab.visualization.cfg import VisualizationCfg from embodichain.utils import configclass, logger @@ -726,6 +728,7 @@ def _initialize_simulation( self._world, num_envs=sim_config.num_envs, spacing=(sim_config.arena_space, sim_config.arena_space, 0.0), + renderer=sim_config.render_cfg.renderer, ) self._arenas = list(self._spawn_scene.builder.prepare_arenas()) self._prepared_spawn_topology_revision = -1 @@ -4814,19 +4817,37 @@ def create_visual_material(self, cfg: VisualMaterialCfg) -> VisualMaterial: """ self._require_native_renderer("Native visual materials") + cfg = cfg.replace(uid=cfg.uid or f"visual_material_{uuid4().hex}") + descriptor = _material_desc_from_cfg(cfg, cfg.uid) + definitions = self._spawn_scene.builder.materials + registered = self._spawn_scene._material_definition(cfg.uid) + if registered is not None and registered != descriptor: + raise ValueError(f"Conflicting visual material definition for {cfg.uid!r}.") if cfg.uid in self._visual_materials: - logger.log_warning( - f"Visual material {cfg.uid} already exists. Returning the existing one." - ) - return self._visual_materials[cfg.uid] - - mat: Material = self._env.create_pbr_material(cfg.uid, True) - visual_mat = VisualMaterial(cfg, mat) + existing = self._visual_materials[cfg.uid] + if _material_desc_from_cfg(existing.cfg, cfg.uid) != descriptor: + raise ValueError( + f"Conflicting visual material definition for {cfg.uid!r}." + ) + return existing + if cfg.preset is not None or cfg.parameters: + self._spawn_scene._resolve_materials({cfg.uid: descriptor}) + mat, native_inst = self._spawn_scene._material_resources.resolve(descriptor) + visual_mat = VisualMaterial( + cfg, mat, initialize_default=False, mat_inst=native_inst + ) + else: + mat: Material | None = self._env.find_material(cfg.uid) + initialize_default = mat is None or registered is None + if mat is None: + mat = self._env.create_pbr_material(cfg.uid, True) + visual_mat = VisualMaterial(cfg, mat, initialize_default=initialize_default) + definitions[cfg.uid] = descriptor self._visual_materials[cfg.uid] = visual_mat return visual_mat - def get_visual_material(self, uid: str) -> VisualMaterial: + def get_visual_material(self, uid: str) -> VisualMaterial | None: """Get visual material by UID. Args: @@ -4838,7 +4859,13 @@ def get_visual_material(self, uid: str) -> VisualMaterial: return self._visual_materials[uid] - def clean_materials(self): + def clean_materials(self) -> None: + """Clear runtime wrappers and ask the native owner to release unused materials. + + Explicit material definitions remain reserved in the Scene for the World + lifetime. Cleaning does not permit a conflicting definition to replace a + UID that declarations or live native handles may still reference. + """ self._visual_materials = {} if self._env: self._env.clean_materials() @@ -4903,7 +4930,7 @@ def reset_objects_state( else: articulation.reset(env_ids) for uid, visual in getattr(self, "_visual_objects", {}).items(): - if uid not in excluded_uids: + if uid not in excluded_uids and visual.is_spawn_bound: visual.reset(env_ids) for uid, rigid_obj in self._rigid_objects.items(): if uid not in excluded_uids: diff --git a/embodichain/lab/sim/spawn/descriptors.py b/embodichain/lab/sim/spawn/descriptors.py index e3088a8deb..a580e12397 100644 --- a/embodichain/lab/sim/spawn/descriptors.py +++ b/embodichain/lab/sim/spawn/descriptors.py @@ -33,6 +33,7 @@ from collections.abc import Sequence from dataclasses import MISSING, dataclass, field, fields, replace import math +import hashlib import numbers import os import warnings @@ -1723,28 +1724,16 @@ def _compile_visual_material( ) -> tuple[str | None, tuple[str, MaterialDesc] | None]: if cfg is None: return None, None - key = str(cfg.uid or f"{object_uid}_material") - base_color = tuple(float(value) for value in cfg.base_color) - if len(base_color) != 4: - raise ValueError("VisualMaterialCfg.base_color must be RGBA.") - emissive_rgb = tuple( - float(value) * float(cfg.emissive_intensity) for value in cfg.emissive - ) - if len(emissive_rgb) != 3: - raise ValueError("VisualMaterialCfg.emissive must be RGB.") - desc = MaterialDesc( - name=key, - base_color=base_color, - base_color_map=cfg.base_color_texture, - normal_map=cfg.normal_texture, - emissive=(*emissive_rgb, 1.0), - roughness=float(cfg.roughness), - roughness_map=cfg.roughness_texture, - metallic=float(cfg.metallic), - metallic_map=cfg.metallic_texture, - ao_map=cfg.ao_texture, - ior=float(cfg.ior), - ) + from embodichain.lab.sim.material import _material_desc_from_cfg + + key = cfg.uid or f"{object_uid}_material" + desc = _material_desc_from_cfg(cfg, key) + if not cfg.uid: + # Replacements may keep the object UID while changing appearance. Reuse + # an implicit template only for the same normalized material definition. + digest = hashlib.sha256(repr(desc).encode("utf-8")).hexdigest()[:16] + key = f"{key}_{digest}" + desc.name = key return key, (key, desc) diff --git a/embodichain/lab/sim/spawn/scene.py b/embodichain/lab/sim/spawn/scene.py index 7d22f16670..ed4bea6ad6 100644 --- a/embodichain/lab/sim/spawn/scene.py +++ b/embodichain/lab/sim/spawn/scene.py @@ -22,6 +22,11 @@ from typing import Any, Callable, Literal from ._usd_assets import _UsdTextureFiles +from .._material_presets import ( + _AssetMaterialDesc, + _MaterialResources, + _validate_backend, +) __all__ = ["SpawnScene"] @@ -41,6 +46,7 @@ class _AssetDeclaration: descriptor: Any facade: Any | None source_configurator: Callable[[Any], None] | None = None + declaration_only: bool = False class SpawnScene: @@ -57,6 +63,7 @@ def __init__( *, num_envs: int, spacing: tuple[float, float, float] = (0.0, 0.0, 0.0), + renderer: str | None = None, ) -> None: from dexsim.spawn import SceneBuilder @@ -70,10 +77,66 @@ def __init__( ) self._assets: dict[str, _AssetDeclaration] = {} self._usd_texture_files = _UsdTextureFiles() + if renderer is None: + from dexsim.types import Renderer + + renderer = { + Renderer.NORENDER: "no-render", + Renderer.HYBRID: "hybrid", + Renderer.FASTRT: "fast-rt", + Renderer.OFFLINERT: "rt", + }[world.get_renderer_type()] + self._material_resources = _MaterialResources(world, renderer) + self._resource_material_definitions: dict[str, _AssetMaterialDesc] = {} + self._pending_material_assignments: ( + dict[tuple[str, int, str | None], list[Any]] | None + ) = None def _resolve_materials(self, materials: dict[str, Any]) -> dict[str, Any]: """Resolve package images before DexSim creates native materials.""" - return self._usd_texture_files.resolve(materials) + resolved = self._usd_texture_files.resolve(materials) + for uid, descriptor in resolved.items(): + existing = self._material_definition(uid) + if existing is not None and existing != descriptor: + raise ValueError(f"Conflicting visual material definition for {uid!r}.") + resources = { + uid: desc + for uid, desc in resolved.items() + if isinstance(desc, _AssetMaterialDesc) + } + for desc in resources.values(): + _validate_backend(desc, self._material_resources.renderer) + for uid, desc in resources.items(): + if self._material_resources.renderer != "no-render": + self._material_resources.resolve(desc) + self._resource_material_definitions[uid] = desc + # Native Spawn's simplified MaterialDesc path cannot express these + # definitions. Bind the exact loaded child directly on each RenderDesc. + # NoRender retains only metadata and never invokes a native loader. + return {uid: desc for uid, desc in resolved.items() if uid not in resources} + + def _material_definition(self, uid: str) -> Any | None: + return getattr(self, "_resource_material_definitions", {}).get( + uid, self.builder.materials.get(uid) + ) + + def _bind_resource_materials(self, descriptor: Any) -> None: + definitions = getattr(self, "_resource_material_definitions", {}) + if not definitions or self._material_resources.renderer == "no-render": + return + if isinstance(descriptor, (tuple, list)): + for member in descriptor: + self._bind_resource_materials(member) + return + reference = getattr(descriptor, "material_ref", None) + if reference in definitions: + _, descriptor.material = self._material_resources.resolve( + definitions[reference] + ) + for name in ("renders", "links", "mesh", "visual_mesh"): + value = getattr(descriptor, name, None) + if value is not None: + self._bind_resource_materials(value) @property def arena_names(self) -> tuple[str, ...]: @@ -95,6 +158,7 @@ def declare( """Add a descriptor and associate it with an EmbodiChain facade.""" if uid in self._assets: raise ValueError(f"Spawn asset uid is already declared: {uid!r}.") + self._bind_resource_materials(descriptor) self._initialize_facade_declaration(facade) declaration = _AssetDeclaration( kind=kind, @@ -102,6 +166,14 @@ def declare( facade=facade, source_configurator=configure_source, ) + if ( + kind == "visual_object" + and getattr(getattr(self, "_material_resources", None), "renderer", None) + == "no-render" + ): + declaration.declaration_only = True + self._assets[uid] = declaration + return if kind == "rigid_object_group": declaration.descriptor = tuple( @@ -116,6 +188,7 @@ def declare( ): self._resolve_articulation_source(descriptor) configure_source(descriptor) + self._bind_resource_materials(descriptor) declaration.source_configurator = None add_name = { "visual_object": "add_object", @@ -161,6 +234,7 @@ def resolve_sources(self) -> None: if configure is None: continue configure(declaration.descriptor) + self._bind_resource_materials(declaration.descriptor) declaration.source_configurator = None def track( @@ -184,6 +258,9 @@ def track( def remove(self, uid: str) -> None: """Remove a declared asset from its DexSim owner.""" declaration = self._assets[uid] + if getattr(declaration, "declaration_only", False): + del self._assets[uid] + return if declaration.kind in {"soft_object", "cloth_object"}: raise NotImplementedError( "DexSim Spawn does not yet expose pending removal for " @@ -202,6 +279,11 @@ def remove(self, uid: str) -> None: if removed is None: raise KeyError(f"Spawn asset is absent from SceneBuilder: {uid!r}.") del self._assets[uid] + pending = getattr(self, "_pending_material_assignments", None) + if pending is not None: + self._pending_material_assignments = { + key: materials for key, materials in pending.items() if key[0] != uid + } def commit(self) -> Any: """Finalize once or let the current ``Scene`` consume pending changes.""" @@ -212,6 +294,10 @@ def commit(self) -> Any: result = self.builder.result assert result is not None if self.builder.has_pending_changes or result.needs_rebuild: + if getattr(self, "_pending_material_assignments", None) is None: + self._pending_material_assignments = ( + self._capture_material_assignments() + ) result = result.rebuild(self.builder) for uid in self._assets: @@ -219,6 +305,52 @@ def commit(self) -> Any: self.builder.result = result return result + def _capture_material_assignments( + self, + ) -> dict[tuple[str, int, str | None], list[Any]]: + """Snapshot current per-segment assignments before native resources rebuild.""" + from embodichain.lab.sim.material import _capture_render_materials + + assignments = {} + for uid, declaration in self._assets.items(): + facade = declaration.facade + if facade is None or not getattr(facade, "is_spawn_bound", False): + continue + links = facade.link_names if declaration.kind == "articulation" else [None] + for instance_idx, entity in enumerate(self.handles(uid)): + for link in links: + render_body = ( + entity.get_render_body() + if link is None + else entity.get_render_body(link) + ) + if render_body is not None: + assignments[(uid, instance_idx, link)] = ( + _capture_render_materials(render_body) + ) + return assignments + + def _restore_material_assignments(self) -> None: + """Replay current assignments; retain the snapshot when preparation must retry.""" + from embodichain.lab.sim.material import _restore_render_materials + + assignments = getattr(self, "_pending_material_assignments", None) + if assignments is None: + return + for (uid, instance_idx, link), materials in assignments.items(): + if uid not in self._assets: + continue + entity = self.handles(uid)[instance_idx] + render_body = ( + entity.get_render_body() + if link is None + else entity.get_render_body(link) + ) + if render_body is None or render_body.get_mesh_count() != len(materials): + raise RuntimeError(f"Render material topology changed for {uid!r}.") + _restore_render_materials(render_body, materials) + self._pending_material_assignments = None + def bind(self) -> None: """Complete post-finalize runtime binding for declared facades. @@ -232,10 +364,21 @@ def bind(self) -> None: if result is None or not self.builder.is_finalized: raise RuntimeError("Spawn scene must be materialized before binding.") + self._restore_material_assignments() for uid, declaration in self._assets.items(): facade = declaration.facade if facade is None or not facade.is_declared: continue + if ( + declaration.kind == "visual_object" + and getattr( + getattr(self, "_material_resources", None), "renderer", None + ) + == "no-render" + ): + # NoRender has no transform batch for render-only actors. + # Keep their geometry/material declarations without binding. + continue if not facade._entities: facade.attach_spawn_handles(self.handles(uid)) facade.bind_spawn(result) @@ -262,6 +405,7 @@ def prepare_runtime_config(self, result: Any) -> None: def close(self) -> None: """Release Spawn resources and facade references.""" result = self.builder.result + self._pending_material_assignments = None try: if result is not None: result.close() @@ -269,6 +413,9 @@ def close(self) -> None: self.builder.result = None self._assets.clear() self._usd_texture_files.close() + if hasattr(self, "_material_resources"): + self._material_resources.close() + self._resource_material_definitions.clear() def handles(self, uid: str) -> tuple[Any, ...]: """Return currently materialized handles for one logical asset.""" diff --git a/examples/sim/demo/scoop_ice.py b/examples/sim/demo/scoop_ice.py index 14d4cfd734..260da2d9c0 100644 --- a/examples/sim/demo/scoop_ice.py +++ b/examples/sim/demo/scoop_ice.py @@ -34,6 +34,8 @@ def build_parser() -> argparse.ArgumentParser: """Build CLI options without initializing simulation resources.""" parser = argparse.ArgumentParser(description="Scoop ice task simulation") add_sim_args_to_parser(parser) + # The ice material uses authored transmission and requires OfflineRT. + parser.set_defaults(renderer="rt") add_seed_arg_to_parser(parser, default=0, scope="ice placement") return parser @@ -351,14 +353,13 @@ def create_ice_cubes(sim: SimulationManager): ice_cubes: RigidObjectGroup = sim.add_rigid_object_group(cfg=ice_cubes_cfg) # Set visual material for ice cubes. - # The material below only works for ray tracing backend. - # Set ior to 1.31 and material type to "BSDF" for better ice appearance. + # Explicit optical parameters select ice scattering in OfflineRT. ice_mat = sim.create_visual_material( cfg=VisualMaterialCfg( base_color=[1.0, 1.0, 1.0, 1.0], ior=1.31, roughness=0.2, - material_type="BSDF", + parameters={"transmission": 1.0}, ) ) sim.prepare() diff --git a/examples/sim/rendering/default_materials.py b/examples/sim/rendering/default_materials.py new file mode 100644 index 0000000000..bc12405337 --- /dev/null +++ b/examples/sim/rendering/default_materials.py @@ -0,0 +1,31 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Export default material previews through the production preview-material CLI. + +This example retains its headless default. Use ``embodichain preview-material`` +for a native window, or pass ``--no-headless`` here. Use ``--all --compatible-only`` +when intentionally exporting only the presets supported by Hybrid or FastRT. +""" + +from __future__ import annotations + +import sys + +from embodichain.lab.scripts.preview_material import cli + +if __name__ == "__main__": + cli(["--headless", *sys.argv[1:]]) diff --git a/setup.py b/setup.py index 6eb220c705..367259147a 100644 --- a/setup.py +++ b/setup.py @@ -177,6 +177,7 @@ def main(): package_dir=get_package_dir(), package_data={ "embodichain": ["VERSION"], + "embodichain.data": ["default_materials.json"], "embodichain_benchmarks.motion_generation": ["suites/*.yaml"], "embodichain.gen_sim.simready_pipeline.configs": ["*.json"], "embodichain_tasks.configs": [ diff --git a/tests/data/test_default_materials.py b/tests/data/test_default_materials.py new file mode 100644 index 0000000000..0064061049 --- /dev/null +++ b/tests/data/test_default_materials.py @@ -0,0 +1,154 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Offline discovery, CLI registration and immutable resource distribution.""" + +from __future__ import annotations + +import hashlib +import zipfile +from pathlib import Path + +import pytest + +from embodichain.data import list_default_materials, get_default_material_info +from embodichain.data.default_materials import _catalog +from embodichain.data.assets.materials import DefaultMaterials +from embodichain.data.download import get_registry + +pytestmark = pytest.mark.no_sim + + +def test_offline_catalog_is_independent_and_includes_geometry_guidance(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("Offline metadata must not construct a dataset") + + monkeypatch.setattr(DefaultMaterials, "__init__", fail) + names = list_default_materials() + assert len(names) == len(set(names)) == 24 + assert "SoapBubbleInterior" not in names + assert ( + sum(get_default_material_info(name)["renderers"] == ["rt"] for name in names) + == 10 + ) + glass = get_default_material_info("GlassClear") + assert glass["renderers"] == ["rt"] + assert glass["nominal_size_m"] == [0.5, 0.5] + glass["renderers"].clear() + assert get_default_material_info("GlassClear")["renderers"] == ["rt"] + assert get_default_material_info("SoapBubble")["geometry_notes"] + assert get_default_material_info("SoapBubbleInterior")["auxiliary"] + assert ( + sum(not get_default_material_info(name)["requires_download"] for name in names) + == 6 + ) + builtin = get_default_material_info("PlasticMatteBlue") + assert builtin["source"] == "builtin" + assert builtin["texture_resolution"] is None + assert builtin["nominal_size_m"] is None + assert builtin["revision"] == "builtin-v1" + assert builtin["parameters"]["base_color"] == [0.05, 0.22, 0.6, 1] + builtin["parameters"]["base_color"][0] = 1.0 + assert ( + get_default_material_info("PlasticMatteBlue")["parameters"]["base_color"][0] + == 0.05 + ) + assert ("DefaultMaterials", DefaultMaterials) in get_registry()["materials"] + with pytest.raises(ValueError, match="Unknown default material"): + get_default_material_info("glassclear") + + +def test_verified_archive_repairs_missing_dependency_in_custom_cache( + tmp_path, monkeypatch +): + # Exercise the production constructor, lock and Open3D cache path without + # depending on a network server in unit tests. + payload = { + "Material/SoapBubble/Color.png": b"original pixels", + "Material/SoapBubbleInterior/material.json": b"{}", + } + archive = tmp_path / "fixture.zip" + with zipfile.ZipFile(archive, "w") as bundle: + for directory in ( + "Material/", + "Material/SoapBubble/", + "Material/SoapBubbleInterior/", + ): + bundle.writestr(directory, b"") + for name, content in payload.items(): + bundle.writestr(name, content) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + metadata = dict( + _catalog(), + bundle={ + **_catalog()["bundle"], + "revision": "test", + "archive_name": "fixture.zip", + "md5": hashlib.md5(archive.read_bytes()).hexdigest(), + "sha256": digest, + "upstream_url": str(archive), + "files": { + name: { + "size_bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + for name, content in payload.items() + }, + }, + ) + monkeypatch.setattr("embodichain.data.assets.materials._catalog", lambda: metadata) + monkeypatch.setattr(DefaultMaterials, "cache_prefix", "DefaultMaterials_test") + monkeypatch.delenv("EMBODICHAIN_DOWNLOAD_PREFIX", raising=False) + # Preseed a valid downloaded archive, as if downloaded on another run. + root = tmp_path / "custom-cache" + download = root / "download/DefaultMaterials_test" + download.mkdir(parents=True) + (download / "fixture.zip").write_bytes(archive.read_bytes()) + extract = root / "extract/DefaultMaterials_test" + extract.mkdir(parents=True) + (extract / "unrelated").write_bytes(b"partial extraction") + first = DefaultMaterials(str(root)) + texture = Path(first.get_material_path("SoapBubble")) / "Color.png" + assert texture.read_bytes() == b"original pixels" + texture.write_bytes(b"corrupted pixels") + second = DefaultMaterials(str(root)) + assert Path(second.extract_dir) == extract + assert texture.read_bytes() == b"original pixels" + assert (extract / "Material/SoapBubbleInterior/material.json").read_bytes() == b"{}" + with pytest.raises(ValueError, match="built-in parameter preset"): + second.get_material_path("PlasticMatteBlue") + + +def test_data_cli_reports_the_versioned_cache_as_ready(tmp_path, monkeypatch, capsys): + from argparse import Namespace + from embodichain.data import download + + monkeypatch.setattr(download, "EMBODICHAIN_DEFAULT_DATA_ROOT", str(tmp_path)) + extracted = tmp_path / "extract" / DefaultMaterials.cache_prefix + extracted.mkdir(parents=True) + (extracted / "Material").mkdir() + download.cmd_list(Namespace(category="materials")) + assert "[✓] DefaultMaterials" in capsys.readouterr().out + observed = [] + monkeypatch.setattr( + download, "_ensure_extract", lambda obj, prefix: observed.append(prefix) + ) + + class CachedMaterials: + cache_prefix = DefaultMaterials.cache_prefix + + assert download.download_asset("DefaultMaterials", CachedMaterials) + assert observed == [DefaultMaterials.cache_prefix] diff --git a/tests/gym/envs/managers/test_randomize_visual_material.py b/tests/gym/envs/managers/test_randomize_visual_material.py index 85cb3c8211..333f0638ac 100644 --- a/tests/gym/envs/managers/test_randomize_visual_material.py +++ b/tests/gym/envs/managers/test_randomize_visual_material.py @@ -184,7 +184,7 @@ def _force_tier(functor, tier: int) -> None: def _run(functor, env, env_ids: torch.Tensor | None = None) -> None: env_ids = torch.arange(env.num_envs) if env_ids is None else env_ids - functor(env, env_ids, entity_cfg=functor.entity_cfg) + functor(env, env_ids, **functor.cfg.params) def test_fallback_to_new_preserves_legacy_path(): @@ -437,3 +437,122 @@ def test_empty_reuse_state_is_a_noop(): assert functor._new_mode is True obj.apply_render_material_inst.assert_not_called() + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_solid_tier_applies_requested_pbr_ranges(fallback): + env, obj, functor = _make_rigid_functor( + { + "fallback_to_new": fallback, + "random_texture_prob": 0.0, + "metallic_range": (0.9, 0.9), + "roughness_range": (0.2, 0.2), + "ior_range": (2.0, 2.0), + } + ) + _force_tier(functor, 2) + + _run(functor, env) + + instance = ( + obj.get_visual_material_inst.return_value[0] + if fallback + else functor._reuse_state[0][0].working_inst + ) + assert instance.set_metallic.call_args.args[0] == pytest.approx(0.9) + assert instance.set_roughness.call_args.args[0] == pytest.approx(0.2) + instance.set_ior.assert_called_once_with(2.0) + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_shared_partial_randomization_rejects_before_any_material_write(fallback): + state = [[_segment()]] + env, obj, functor = _make_rigid_functor( + {"shared": True, "fallback_to_new": fallback}, num_envs=2, states=state + ) + obj.set_visual_material.reset_mock() + + with pytest.raises(ValueError, match="requires all environments"): + _run(functor, env, torch.tensor([0])) + + obj.set_visual_material.assert_not_called() + obj.apply_render_material_inst.assert_not_called() + state[0][0].working_inst.set_base_color.assert_not_called() + + +def test_shared_full_randomization_mutates_once_and_attaches_all_selected_rows(): + segment = _segment() + env, obj, functor = _make_rigid_functor( + {"shared": True}, num_envs=3, states=[[segment]] + ) + _force_tier(functor, 2) + + _run(functor, env, torch.tensor([2, 0, 1])) + + segment.working_inst.set_base_color_texture.assert_called_once() + assert [c.args[0] for c in obj.apply_render_material_inst.call_args_list] == [ + 2, + 0, + 1, + ] + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_empty_material_selection_does_not_mutate_or_allocate(fallback): + env, obj, functor = _make_rigid_functor({"fallback_to_new": fallback}) + obj.set_visual_material.reset_mock() + env.sim.env.reset_mock() + + _run(functor, env, torch.tensor([], dtype=torch.int64)) + + obj.set_visual_material.assert_not_called() + obj.apply_render_material_inst.assert_not_called() + env.sim.env.create_color_texture.assert_not_called() + env.sim.env.clean_materials.assert_not_called() + + +def test_partial_articulation_randomization_samples_only_selected_links(): + env = _MockEnv(num_envs=3) + art = _MockArticulation(["arm", "eef"], num_envs=3) + states = [{name: [_segment()] for name in art.link_names} for _ in range(3)] + art.get_existing_visual_material = MagicMock(return_value=states) + env.sim._asset = art + env.sim.asset_uids = ["art"] + cfg = _make_cfg(uid="art") + cfg.params["entity_cfg"].link_names = art.link_names + functor = randomize_visual_material(cfg, env) + functor._sample_tiers = MagicMock(return_value=torch.tensor([2, 2])) + + _run(functor, env, torch.tensor([2])) + + functor._sample_tiers.assert_called_once_with(2) + assert all(c.args[0] == 2 for c in art.apply_render_material_inst.call_args_list) + for unselected in states[:2]: + for segments in unselected.values(): + segments[0].working_inst.set_base_color_texture.assert_not_called() + + +def test_texture_libraries_with_same_basename_remain_independent(monkeypatch, tmp_path): + first_path = tmp_path / "a" / "textures" + second_path = tmp_path / "b" / "textures" + pixels = { + str(first_path): torch.zeros((2, 2, 4), dtype=torch.uint8), + str(second_path): torch.full((2, 2, 4), 255, dtype=torch.uint8), + } + loads = [] + + def load_images(path): + loads.append(path) + return [pixels[path]] + + monkeypatch.setattr("embodichain.utils.utility.read_all_folder_images", load_images) + env = _MockEnv() + first = randomize_visual_material(_make_cfg({"texture_path": str(first_path)}), env) + second = randomize_visual_material( + _make_cfg({"texture_path": str(second_path)}), env + ) + + assert loads == [str(first_path), str(second_path)] + assert first.textures[0] is pixels[str(first_path)] + assert second.textures[0] is pixels[str(second_path)] + assert first._library_textures is not second._library_textures diff --git a/tests/lab/scripts/test_preview_material.py b/tests/lab/scripts/test_preview_material.py new file mode 100644 index 0000000000..ccada270e3 --- /dev/null +++ b/tests/lab/scripts/test_preview_material.py @@ -0,0 +1,233 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Material-preview CLI selection, offline discovery and resource boundaries.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from embodichain.lab.scripts import preview_material + +pytestmark = pytest.mark.no_sim +_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize("option", ["--help", "--list"]) +def test_catalog_commands_need_no_native_imports_or_downloads(tmp_path, option) -> None: + script = f""" +import importlib.abc +import socket +import sys +sys.path.insert(0, {_ROOT.as_posix()!r}) +class NoRendering(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, *args): + if fullname.split('.')[0] in {{'dexsim', 'torch', 'numpy', 'cv2', 'PIL', 'matplotlib'}}: + raise AssertionError('Catalog query imported ' + fullname) +sys.meta_path.insert(0, NoRendering()) +def no_network(*args, **kwargs): + raise AssertionError('Catalog query used the network') +socket.socket.connect = no_network +from embodichain.cli.main import main +main(['preview-material', {option!r}]) +""" + result = subprocess.run( + [sys.executable, "-I", "-c", script], + cwd=tmp_path, + env={**os.environ, "EMBODICHAIN_DATA_ROOT": str(tmp_path / "assets")}, + text=True, + capture_output=True, + timeout=30, + check=True, + ) + if option == "--list": + records = [json.loads(line) for line in result.stdout.splitlines()] + assert len(records) == 24 + assert {record["source"] for record in records} == {"builtin", "resource"} + assert "SoapBubbleInterior" not in {record["name"] for record in records} + else: + assert "usage: embodichain preview-material" in result.stdout + assert "--material-params" in result.stdout + assert not (tmp_path / "assets").exists() + assert not (tmp_path / "outputs").exists() + + +def test_list_source_filter_is_offline(capsys) -> None: + preview_material.cli(["--list", "--source", "builtin"]) + records = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + assert len(records) == 6 + assert all(record["requires_download"] is False for record in records) + + +@pytest.fixture +def captures(monkeypatch): + from embodichain.lab.sim import SimulationManager + + calls = [] + monkeypatch.setattr(SimulationManager, "flush_cleanup_queue", lambda: None) + + def render(args, configs, skipped): + calls.append((args, configs, skipped)) + return [] + + monkeypatch.setattr(preview_material, "_render_materials", render) + return calls + + +def test_default_preview_inherits_authored_fields_and_requests_window(captures) -> None: + preview_material.cli([]) + args, configs, skipped = captures[0] + assert args.renderer == "rt" and args.headless is False + assert configs[0].preset == "GlassClear" and configs[0].ior is None + assert configs[0].parameters == {} and skipped == [] + + +def test_overrides_keep_explicit_zero_false_and_none(captures) -> None: + preview_material.cli( + [ + "--material", + "GlassClear", + "--headless", + "--material-params", + '{"ior":null,"roughness":0,"parameters":{"thin_walled":false,"volume_attenuation_distance":8}}', + ] + ) + args, configs, skipped = captures[0] + cfg = configs[0] + assert args.headless and not skipped + assert cfg.ior is None and cfg.roughness == 0 + assert cfg.parameters == {"thin_walled": False, "volume_attenuation_distance": 8} + + +@pytest.mark.parametrize( + "argv, message", + [ + (["--all", "--material", "GlassClear"], "not allowed"), + (["--material-params", "[]"], "JSON object"), + (["--material-params", "{broken"], "JSON object"), + (["--material-params", '{"uid":"caller"}'], "preview UIDs"), + (["--spp", "0"], "positive integer"), + (["--renderer", "no-render"], "invalid choice"), + (["--headless", "--max-steps", "1"], "native-window"), + (["--source", "builtin"], "requires --list or --all"), + (["--compatible-only"], "requires --all"), + (["--compare-native", "--material-params", '{"ior":1.6}'], "authored presets"), + (["--material", "unknown"], "Unknown default material"), + (["--renderer", "hybrid"], "requires renderer='rt'"), + (["--all", "--renderer", "fast-rt"], "requires renderer='rt'"), + ( + ["--material-params", '{"roughness":0,"parameters":{"roughness":0.1}}'], + "Duplicate", + ), + (["--material-params", '{"parameters":{"transmission":-1}}'], "Invalid visual"), + ( + ["--material-params", '{"material_type":"BSDF"}'], + "material_type", + ), + ( + [ + "--material", + "PlasticMatteBlue", + "--renderer", + "hybrid", + "--material-params", + '{"parameters":{"clearcoat":0}}', + ], + "requires renderer='rt'", + ), + ( + [ + "--all", + "--compatible-only", + "--renderer", + "hybrid", + "--material-params", + '{"parameters":{"clearcoat":0}}', + ], + "No selected presets", + ), + ], +) +def test_invalid_requests_fail_before_rendering( + captures, capsys, argv, message +) -> None: + with pytest.raises(SystemExit) as error: + preview_material.cli(argv) + assert error.value.code == 2 + assert message in capsys.readouterr().err + assert not captures + + +@pytest.mark.parametrize("renderer", ["hybrid", "fast-rt"]) +def test_explicit_compatible_batch_reports_every_skipped_preset( + captures, capsys, renderer +) -> None: + preview_material.cli(["--all", "--compatible-only", "--renderer", renderer]) + args, configs, skipped = captures[0] + assert args.all + assert {cfg.preset for cfg in configs} == { + "PlasticMatteBlue", + "PlasticGlossyRed", + "GoldMetal", + "MirrorMetal", + "BrickRed", + "WoodOak", + "CeramicTileWhite", + "MarbleWhite", + "AsphaltDark", + "FabricGray", + "LeatherBrown", + "PlasticABSBlack", + "RubberBlack", + "SteelBrushed", + } + assert {item["name"] for item in skipped} == { + "GlassClearSurface", + "GlassFrostedSurface", + "ChromePolished", + "GoldPolished", + "CopperPolished", + "GlassClear", + "GlassFrosted", + "GlassSmoke", + "SkinHumanLight", + "SoapBubble", + } + stderr = capsys.readouterr().err + assert all(f"Skipping {item['name']}:" in stderr for item in skipped) + + +def test_render_failure_still_drains_native_cleanup(monkeypatch) -> None: + from embodichain.lab.sim import SimulationManager + + flushed = [] + + def fail(*args): + raise RuntimeError("capture failed") + + monkeypatch.setattr(preview_material, "_render_materials", fail) + monkeypatch.setattr( + SimulationManager, "flush_cleanup_queue", lambda: flushed.append(True) + ) + with pytest.raises(RuntimeError, match="capture failed"): + preview_material.cli(["--headless", "--material", "PlasticMatteBlue"]) + assert flushed == [True] diff --git a/tests/sim/motion/planners/test_curobo_planner.py b/tests/sim/motion/planners/test_curobo_planner.py index bccdae0e49..80f3dae22e 100644 --- a/tests/sim/motion/planners/test_curobo_planner.py +++ b/tests/sim/motion/planners/test_curobo_planner.py @@ -1172,7 +1172,7 @@ class _FakeDexsimMaterial: def __init__(self, name, color): self.name = name self.color = color - self.pbr_type = None + self.transmission = None self.pbr_params = {} def set_base_color(self, color): @@ -1181,8 +1181,8 @@ def set_base_color(self, color): def get_inst(self): return self - def update_pbr_material_type(self, material_type): - self.pbr_type = material_type + def set_transmission(self, value): + self.transmission = value def set_pbr_param(self, name, *values): self.pbr_params[name] = values @@ -1261,6 +1261,9 @@ def test_obstacle_collision_visualization_loads_one_combined_dexsim_actor(): assert bounds.get_extent() == pytest.approx([0.1, 0.1, 0.1]) assert actors[0].material.name == "curobo_world_collision_material" assert actors[0].material.color == [1.0, 0.0, 0.0] + assert actors[0].material.transmission == pytest.approx(0.55) + assert "colorAbsorption" not in actors[0].material.pbr_params + assert "scaleAbsorption" not in actors[0].material.pbr_params def test_combined_collision_visualization_colors_and_cleans_two_actors( @@ -1324,11 +1327,13 @@ def get_link_pose( assert robot_bounds.get_extent() == pytest.approx([0.22, 0.22, 0.22]) assert robot_actor.material.name == "curobo_robot_collision_material" assert robot_actor.material.color == [0.75, 0.75, 1.0] + assert robot_actor.material.transmission == pytest.approx(0.55) obstacle_bounds = obstacle_actor.mesh.get_axis_aligned_bounding_box() assert obstacle_bounds.get_center() == pytest.approx([2.0, 2.0, 3.0]) assert obstacle_bounds.get_extent() == pytest.approx([0.1, 0.1, 0.1]) assert obstacle_actor.material.name == "curobo_world_collision_material" assert obstacle_actor.material.color == [1.0, 0.75, 0.75] + assert obstacle_actor.material.transmission == pytest.approx(0.55) assert env.removed_actors == list(reversed(env.actors)) assert all(not Path(path).exists() for path in env.loaded_paths) assert "Showing 2 cuRobo collision spheres" in prompts[0] diff --git a/tests/sim/objects/test_articulation.py b/tests/sim/objects/test_articulation.py index 8b5ff0b334..102ea2aa37 100644 --- a/tests/sim/objects/test_articulation.py +++ b/tests/sim/objects/test_articulation.py @@ -963,18 +963,12 @@ def test_set_visual_material(self): assert ( "handle_xpos" in mat_insts[0] ), "FAIL: 'handle_xpos' not in material instances" - assert mat_insts[0]["outer_box"].base_color == [ - 0.0, - 0.0, - 1.0, - 1.0, - ], f"FAIL: 'outer_box' base color not set correctly: {mat_insts[0]['outer_box'].base_color}" - assert mat_insts[0]["handle_xpos"].base_color == [ - 0.0, - 0.0, - 1.0, - 1.0, - ], f"FAIL: 'handle_xpos' base color not set correctly: {mat_insts[0]['handle_xpos'].base_color}" + assert mat_insts[0]["outer_box"].base_color == pytest.approx( + [0.0, 0.0, 1.0] + ), f"FAIL: 'outer_box' base color not set correctly: {mat_insts[0]['outer_box'].base_color}" + assert mat_insts[0]["handle_xpos"].base_color == pytest.approx( + [0.0, 0.0, 1.0] + ), f"FAIL: 'handle_xpos' base color not set correctly: {mat_insts[0]['handle_xpos'].base_color}" # TODO: Open this test will cause segfault in CI env # def test_get_link_pose(self): diff --git a/tests/sim/objects/test_articulation_reuse_material.py b/tests/sim/objects/test_articulation_reuse_material.py index 7fed2f8a80..0ee23e6c6e 100644 --- a/tests/sim/objects/test_articulation_reuse_material.py +++ b/tests/sim/objects/test_articulation_reuse_material.py @@ -30,6 +30,7 @@ def __init__(self, entities, uid, link_names): # num_instances is a read-only property on BatchEntity, driven by _entities. self._entities = entities self._all_indices = list(range(len(entities))) + self._visual_material = [{} for _ in entities] self.uid = uid self.link_names = link_names diff --git a/tests/sim/objects/test_asset_material_initialization.py b/tests/sim/objects/test_asset_material_initialization.py index a4ab33ac49..10a149d415 100644 --- a/tests/sim/objects/test_asset_material_initialization.py +++ b/tests/sim/objects/test_asset_material_initialization.py @@ -45,6 +45,7 @@ def asset_type(request): def _material(name: str): material = MagicMock(name=name) material.get_name.return_value = name + material.get_material_id.return_value = hash(name) template = MagicMock(name=f"{name}_template") template.get_name.return_value = f"{name}_template" material.get_template.return_value = template diff --git a/tests/sim/objects/test_deformable_object.py b/tests/sim/objects/test_deformable_object.py index 01867fd120..0f0a0191e9 100644 --- a/tests/sim/objects/test_deformable_object.py +++ b/tests/sim/objects/test_deformable_object.py @@ -216,7 +216,8 @@ def test_manager_declares_deformables_with_supported_solver( add_cloth_object=lambda descriptor: descriptor, ) monkeypatch.setattr("dexsim.spawn.SceneBuilder", lambda _world: builder) - sim._spawn_scene = SpawnScene(object(), num_envs=2) + # This declaration-only World stub has no native renderer query interface. + sim._spawn_scene = SpawnScene(object(), num_envs=2, renderer="hybrid") try: facade = sim.add_deformable_object( diff --git a/tests/sim/objects/test_rigid_object.py b/tests/sim/objects/test_rigid_object.py index 42e688ca57..b064bdf3e9 100644 --- a/tests/sim/objects/test_rigid_object.py +++ b/tests/sim/objects/test_rigid_object.py @@ -447,12 +447,9 @@ def test_set_visual_material(self): len(material_list) == NUM_ARENAS ), f"Expected {NUM_ARENAS} materials, got {len(material_list)}" for mat_inst in material_list: - assert mat_inst.base_color == [ - 0.0, - 0.0, - 1.0, - 1.0, - ], f"Material base color incorrect: {mat_inst.base_color}" + assert mat_inst.base_color == pytest.approx( + [0.0, 0.0, 1.0] + ), f"Material base color incorrect: {mat_inst.base_color}" def test_add_cube(self): cfg_dict = { @@ -864,12 +861,9 @@ def test_misc_properties(self): len(cube_materials) == NUM_ARENAS ), f"Cube should have {NUM_ARENAS} material instances" for i in range(NUM_ARENAS): - assert cube_materials[i].base_color == [ - 0.0, - 0.0, - 1.0, - 1.0, - ], f"Material {i} base color incorrect" + assert cube_materials[i].base_color == pytest.approx( + [0.0, 0.0, 1.0] + ), f"Material {i} base color incorrect" def test_geometry_data(self): """Test mesh-level read APIs: get_triangles and scaled get_vertices. diff --git a/tests/sim/objects/test_rigid_object_reuse_material.py b/tests/sim/objects/test_rigid_object_reuse_material.py index 2571d8e09d..36c6b41f0a 100644 --- a/tests/sim/objects/test_rigid_object_reuse_material.py +++ b/tests/sim/objects/test_rigid_object_reuse_material.py @@ -30,6 +30,7 @@ def __init__(self, entities, uid): # num_instances is a read-only property on BatchEntity, driven by _entities. self._entities = entities self._all_indices = list(range(len(entities))) + self._visual_material = [None] * len(entities) self.uid = uid @@ -64,6 +65,8 @@ def test_get_existing_visual_material_builds_state_per_segment(): assert states[0][0].working_inst is states[0][1].working_inst seg_mats[0][1].create_inst.assert_called_once() seg_mats[1][1].create_inst.assert_not_called() + assert states[0][0].working_inst.mat is seg_mats[0][1].create_inst.return_value + seg_mats[0][1].get_inst.assert_not_called() def test_get_existing_visual_material_shared_returns_single_env(): @@ -104,3 +107,4 @@ def test_apply_render_material_inst_skips_attached_material(): obj.apply_render_material_inst(0, inst) render_body.set_material.assert_not_called() + assert obj.get_visual_material_inst()[0].mat is inst diff --git a/tests/sim/spawn/test_scene.py b/tests/sim/spawn/test_scene.py index aeea7eb87e..2dc2f62c7e 100644 --- a/tests/sim/spawn/test_scene.py +++ b/tests/sim/spawn/test_scene.py @@ -31,10 +31,24 @@ Robot, ) from embodichain.lab.sim.spawn.scene import SpawnScene +from dexsim.spawn import MaterialDesc pytestmark = pytest.mark.no_sim +def test_material_resolution_rejects_conflicting_explicit_definitions(): + scene = object.__new__(SpawnScene) + original = MaterialDesc(name="shared", roughness=0.2) + scene.builder = SimpleNamespace(materials={"shared": original}) + scene._usd_texture_files = SimpleNamespace(resolve=lambda materials: materials) + + assert scene._resolve_materials({"shared": original}) == {"shared": original} + with pytest.raises(ValueError, match="Conflicting visual material"): + scene._resolve_materials({"shared": MaterialDesc(name="shared", roughness=0.9)}) + + assert scene.builder.materials["shared"] is original + + def _make_scene(handles: dict[str, object]) -> SpawnScene: scene = object.__new__(SpawnScene) scene._num_envs = 1 @@ -46,6 +60,110 @@ def _make_scene(handles: dict[str, object]) -> SpawnScene: return scene +def _material_assignment_scene(kind: str): + originals = [MagicMock(name=f"original_{i}") for i in range(2)] + stale = MagicMock(name="stale_descriptor_material") + for i, material in enumerate([*originals, stale]): + material.get_name.return_value = str(i) + material.get_template.return_value.get_name.return_value = "template" + current = list(originals) + body = MagicMock(name="render_body") + body.get_mesh_count.return_value = len(current) + body.get_material.side_effect = lambda mesh_id: current[mesh_id] + body.set_material.side_effect = lambda mesh_id, material: current.__setitem__( + mesh_id, material + ) + entity = SimpleNamespace(get_render_body=lambda *args: body) + scene = _make_scene({"asset": entity}) + facade = SimpleNamespace( + is_spawn_bound=True, + is_declared=False, + link_names=["link"], + attach_spawn_handles=lambda handles: None, + ) + scene.track( + kind, "asset", SimpleNamespace(name="asset", per_env=False), facade=facade + ) + result = scene.builder.result + result.needs_rebuild = True + scene.builder.has_pending_changes = True + + def rebuild(builder): + current[:] = [stale] * len(current) + builder.has_pending_changes = False + result.needs_rebuild = False + return result + + result.rebuild = rebuild + return scene, body, current, originals + + +@pytest.mark.parametrize( + "kind", ["rigid_object", "articulation", "soft_object", "cloth_object"] +) +def test_rebuild_preserves_current_per_segment_materials_over_stale_descriptors(kind): + scene, _, current, originals = _material_assignment_scene(kind) + + scene.commit() + assert ( + current != originals + ) # Model the native rebuild consuming stale descriptions. + scene.bind() + + assert current == originals + assert scene._pending_material_assignments is None + + +def test_material_replay_retries_after_partial_native_failure(): + scene, body, current, originals = _material_assignment_scene("rigid_object") + scene.commit() + body.set_material.side_effect = RuntimeError("native assignment failed") + + with pytest.raises(RuntimeError, match="native assignment failed"): + scene.bind() + + assert scene._pending_material_assignments is not None + body.set_material.side_effect = lambda mesh_id, material: current.__setitem__( + mesh_id, material + ) + scene.bind() + assert current == originals + assert scene._pending_material_assignments is None + + +def test_removing_failed_replay_discards_materials_before_same_uid_replacement(): + scene, body, _, _ = _material_assignment_scene("rigid_object") + scene.commit() + body.set_material.side_effect = RuntimeError("native assignment failed") + with pytest.raises(RuntimeError, match="native assignment failed"): + scene.bind() + + scene.builder.remove_object = lambda name: scene._assets[name].descriptor + scene.remove("asset") + replacement_material = MagicMock(name="replacement_material") + replacement_body = MagicMock() + replacement_body.get_mesh_count.return_value = ( + 1 # Different topology from the removed object. + ) + replacement_body.get_material.return_value = replacement_material + scene.builder.result.handles["asset"] = SimpleNamespace( + get_render_body=lambda: replacement_body + ) + facade = _RetryableFacade() + scene.track( + "rigid_object", + "asset", + SimpleNamespace(name="asset", per_env=False), + facade=facade, + ) + + scene.bind() + + assert facade.bind_attempts == 1 + replacement_body.set_material.assert_not_called() + assert scene._pending_material_assignments is None + + class _RetryableFacade: def __init__(self, *, fail_first: bool = False) -> None: self._entities: list[object] = [] @@ -511,3 +629,23 @@ def test_replacement_declaration_does_not_attach_uncommitted_old_handle() -> Non scene.builder.has_pending_changes = False scene.bind() assert facade._entities == [new_handle] + + +def test_no_render_retains_visual_declaration_without_native_transform_binding(): + scene = _make_scene({}) + scene._material_resources = SimpleNamespace(renderer="no-render") + facade = MagicMock() + facade.is_declared = True + scene.builder.add_object = MagicMock() + scene.declare( + "visual_object", "visual", SimpleNamespace(name="visual"), facade=facade + ) + + scene.bind() + + facade.bind_spawn.assert_not_called() + facade.attach_spawn_handles.assert_not_called() + scene.builder.add_object.assert_not_called() + assert "visual" in scene._assets + scene.remove("visual") + assert "visual" not in scene._assets diff --git a/tests/sim/test_material.py b/tests/sim/test_material.py new file mode 100644 index 0000000000..51d567909a --- /dev/null +++ b/tests/sim/test_material.py @@ -0,0 +1,672 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Material identity, native state, and configuration-lowering contracts.""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from embodichain.lab.sim.material import ( + VisualMaterial, + VisualMaterialCfg, + VisualMaterialInst, +) +from embodichain.lab.sim.material import _set_render_material +from embodichain.lab.sim.sim_manager import SimulationManager +from embodichain.lab.sim.spawn.descriptors import _compile_visual_material +from embodichain.lab.sim.spawn.scene import SpawnScene +from embodichain.lab.sim.objects.rigid_object_group import RigidObjectGroup +from embodichain.lab.gym.envs.managers.cfg import SceneEntityCfg +from embodichain.lab.gym.envs.managers.randomization.visual import ( + set_rigid_object_group_visual_material, +) + +pytestmark = pytest.mark.no_sim + + +def test_material_assignment_uses_native_identity_even_when_names_match(): + original = MagicMock(name="original_handle") + replacement = MagicMock(name="replacement_handle") + for native in (original, replacement): + native.get_name.return_value = "same_name" + native.get_template.return_value.get_name.return_value = "same_template" + original.get_material_id.return_value = 1 + replacement.get_material_id.return_value = 2 + body = MagicMock() + body.get_material.return_value = original + + _set_render_material(body, 0, replacement) + + body.set_material.assert_called_once_with(0, replacement) + + +def _native_template() -> MagicMock: + template = MagicMock(name="template") + instances = {} + + def get_instance(uid=""): + if uid not in instances: + state = { + "base_color": [0.5, 0.5, 0.5, 1.0], + "metallic": 0.0, + "roughness": 0.7, + "ior": 1.5, + "emissive": [0.0, 0.0, 0.0, 1.0], + } + native = MagicMock(name=uid) + native.get_name.return_value = uid + native.get_template.return_value = template + native.get_base_color_map.return_value = None + for field in state: + getattr(native, f"get_{field}").side_effect = lambda key=field: state[ + key + ] + getattr(native, f"set_{field}").side_effect = ( + lambda value, key=field: state.__setitem__(key, value) + ) + native.set_pbr_param.side_effect = lambda key, value: state.__setitem__( + key, value + ) + instances[uid] = native + return instances[uid] + + template.get_inst.side_effect = get_instance + return template + + +def test_repeated_instance_creation_preserves_identity_properties_and_capacity(): + material = VisualMaterial(VisualMaterialCfg(uid="paint"), _native_template()) + instance = material.create_instance("object") + instance.set_roughness(0.2) + instance.mat.reset_mock() + + for _ in range(1025): # Exceeds the historical bounded-texture stress case. + assert material.create_instance("object") is instance + assert material.get_instance("object") is instance + + assert instance.roughness == pytest.approx(0.2) + assert len(material._instances) == 2 # Template default plus one object instance. + instance.mat.set_roughness.assert_not_called() + + +def test_instance_properties_read_current_native_values(): + material = VisualMaterial(VisualMaterialCfg(uid="paint"), _native_template()) + instance = material.create_instance("object") + instance.mat.set_roughness(0.9) + instance.mat.set_metallic(0.6) + instance.mat.set_base_color([0.1, 0.2, 0.3, 1.0]) + instance.mat.set_pbr_param("ior", 2.0) + + queried = material.get_instance("object") + + assert queried.roughness == pytest.approx(0.9) + assert queried.metallic == pytest.approx(0.6) + assert queried.ior == pytest.approx(2.0) + assert queried.base_color == pytest.approx([0.1, 0.2, 0.3]) + + +@pytest.mark.parametrize( + ("setter", "value", "native_setter"), + [ + ("set_base_color", [0.1, 0.2, 0.3, 1.0], "set_base_color"), + ("set_metallic", 0.8, "set_metallic"), + ("set_roughness", 0.2, "set_roughness"), + ("set_emissive", [0.1, 0.2, 0.3], "set_emissive"), + ("set_ior", 2.0, "set_pbr_param"), + ("set_base_color_texture", "color.png", "set_base_color_map"), + ("set_metallic_texture", "metal.png", "set_metallic_map"), + ("set_roughness_texture", "rough.png", "set_roughness_map"), + ("set_normal_texture", "normal.png", "set_normal_map"), + ("set_ao_texture", "ao.png", "set_ao_map"), + ], +) +def test_asset_wrapper_setters_use_exact_existing_handle(setter, value, native_setter): + template = _native_template() + original = template.get_inst("parsed") + wrapper = VisualMaterialInst.from_existing(original) + template.reset_mock() + + getattr(wrapper, setter)(value) + + assert wrapper.mat is original + template.get_inst.assert_not_called() + getattr(original, native_setter).assert_called_once() + + +@pytest.mark.parametrize("intensity", [0.0, 1.0, 10.0]) +def test_runtime_and_spawn_lower_identical_material_properties(intensity): + cfg = VisualMaterialCfg( + uid="paint", + base_color=[0.1, 0.2, 0.3, 0.5], + metallic=0.8, + roughness=0.2, + emissive=[0.2, 0.3, 0.4], + emissive_intensity=intensity, + ior=2.0, + ) + _, (_, descriptor) = _compile_visual_material("object", cfg) + material = VisualMaterial(cfg, _native_template()) + native = material.inst.mat + + assert material.inst.base_color == pytest.approx(descriptor.base_color[:3]) + assert material.inst.metallic == pytest.approx(descriptor.metallic) + assert material.inst.roughness == pytest.approx(descriptor.roughness) + assert material.inst.ior == pytest.approx(descriptor.ior) + np.testing.assert_allclose( + native.set_emissive.call_args.args[0], descriptor.emissive + ) + assert descriptor.material_type is None + native.update_pbr_material_type.assert_not_called() + assert descriptor.has_alpha is True + + +def test_emissive_intensity_can_be_zero_then_restored_without_losing_rgb(): + instance = VisualMaterial( + VisualMaterialCfg(uid="glow", emissive=[0.2, 0.3, 0.4]), _native_template() + ).inst + + instance.set_emissive_intensity(0.0) + np.testing.assert_allclose(instance.mat.get_emissive(), [0.0, 0.0, 0.0, 1.0]) + instance.set_emissive_intensity(2.0) + + np.testing.assert_allclose(instance.mat.get_emissive(), [0.4, 0.6, 0.8, 1.0]) + assert instance.emissive == pytest.approx([0.2, 0.3, 0.4]) + + +@pytest.mark.parametrize( + "properties", + [ + {"base_color": [1.0, 0.0, 0.0]}, + {"base_color": [1.0, 0.0, 0.0, 2.0]}, + {"emissive": [0.0, 0.0]}, + {"metallic": float("nan")}, + {"roughness": 1.1}, + {"ior": 0.0}, + {"emissive_intensity": -1.0}, + {"emissive": [1e308, 0.0, 0.0], "emissive_intensity": 1e308}, + ], +) +def test_invalid_material_config_is_rejected_before_native_instance_creation( + properties, +): + cfg = VisualMaterialCfg(uid="invalid", **properties) + template = _native_template() + + with pytest.raises(ValueError): + VisualMaterial(cfg, template) + with pytest.raises(ValueError): + _compile_visual_material("object", cfg) + + template.get_inst.assert_not_called() + + +def test_unnamed_inline_materials_have_independent_object_scoped_names(): + red_ref, (_, red) = _compile_visual_material( + "red", VisualMaterialCfg(base_color=[1, 0, 0, 1]) + ) + blue_ref, (_, blue) = _compile_visual_material( + "blue", VisualMaterialCfg(base_color=[0, 0, 1, 1]) + ) + definitions = {red_ref: red, blue_ref: blue} + + assert len(definitions) == 2 + assert definitions[red_ref].base_color == (1.0, 0.0, 0.0, 1.0) + assert definitions[blue_ref].base_color == (0.0, 0.0, 1.0, 1.0) + + +def _material_manager(): + manager = SimulationManager.__new__(SimulationManager) + manager._visual_materials = {} + manager._require_native_renderer = MagicMock() + manager._env = MagicMock() + manager._env.find_material.return_value = None + manager._env.create_pbr_material.side_effect = lambda *args: _native_template() + scene = SpawnScene.__new__(SpawnScene) + scene.builder = SimpleNamespace(materials={}) + scene._usd_texture_files = SimpleNamespace(resolve=lambda materials: materials) + manager._spawn_scene = scene + return manager + + +def test_explicit_runtime_uid_reuses_only_equivalent_definitions(): + manager = _material_manager() + cfg = VisualMaterialCfg(uid="shared") + + first = manager.create_visual_material(cfg) + assert manager.create_visual_material(cfg.copy()) is first + with pytest.raises(ValueError, match="Conflicting visual material"): + manager.create_visual_material(cfg.replace(roughness=0.1)) + + manager._env.create_pbr_material.assert_called_once() + + +def test_unnamed_runtime_materials_receive_distinct_ids_without_mutating_config(): + manager = _material_manager() + cfg = VisualMaterialCfg() + + first = manager.create_visual_material(cfg) + second = manager.create_visual_material(cfg) + + assert first.uid != second.uid + assert cfg.uid is None + + +@pytest.mark.parametrize("runtime_first", [False, True]) +def test_explicit_uid_conflicts_are_rejected_across_creation_paths(runtime_first): + manager = _material_manager() + runtime_cfg = VisualMaterialCfg(uid="shared", base_color=[1, 0, 0, 1]) + _, (uid, inline_desc) = _compile_visual_material( + "object", VisualMaterialCfg(uid="shared", base_color=[0, 0, 1, 1]) + ) + scene = manager._spawn_scene + if runtime_first: + manager.create_visual_material(runtime_cfg) + with pytest.raises(ValueError, match="Conflicting visual material"): + scene._resolve_materials({uid: inline_desc}) + else: + scene.builder.materials.update(scene._resolve_materials({uid: inline_desc})) + with pytest.raises(ValueError, match="Conflicting visual material"): + manager.create_visual_material(runtime_cfg) + manager._env.create_pbr_material.assert_not_called() + + +def test_equivalent_inline_material_uses_configured_native_default_after_runtime_creation(): + from dexsim.spawn.adapters.common import _create_render_material + + manager = _material_manager() + cfg = VisualMaterialCfg(uid="shared", base_color=[1, 0, 0, 1]) + runtime = manager.create_visual_material(cfg) + manager._env.find_material.return_value = runtime.mat + _, (_, descriptor) = _compile_visual_material("object", cfg) + manager._spawn_scene._resolve_materials({"shared": descriptor}) + + native = _create_render_material( + SimpleNamespace(get_env=lambda: manager._env), descriptor + ) + + assert native is runtime.inst.mat + assert list(native.get_base_color())[:3] == [1.0, 0.0, 0.0] + + +def test_equivalent_runtime_alias_preserves_native_state_from_inline_material(): + manager = _material_manager() + cfg = VisualMaterialCfg(uid="shared", roughness=0.2) + _, (_, descriptor) = _compile_visual_material("object", cfg) + manager._spawn_scene.builder.materials["shared"] = descriptor + native = _native_template() + native.get_inst().set_roughness(0.8) + native.get_inst().reset_mock() + manager._env.find_material.return_value = native + + alias = manager.create_visual_material(cfg) + + assert alias.inst.roughness == pytest.approx(0.8) + alias.inst.mat.set_roughness.assert_not_called() + manager._env.create_pbr_material.assert_not_called() + + +def test_cleaning_materials_preserves_world_lifetime_definition_identity(): + manager = _material_manager() + cfg = VisualMaterialCfg(uid="reserved", roughness=0.2) + manager.create_visual_material(cfg) + + manager.clean_materials() + + assert manager.get_visual_material("reserved") is None + manager._env.clean_materials.assert_called_once() + with pytest.raises(ValueError, match="Conflicting visual material"): + manager.create_visual_material(cfg.replace(roughness=0.9)) + assert manager.create_visual_material(cfg).cfg.roughness == 0.2 + + +def test_implicit_names_allow_replacement_and_reuse_equal_material_definitions(): + cfg = VisualMaterialCfg(base_color=[1, 0, 0, 1]) + first_ref, first_entry = _compile_visual_material("object", cfg) + second_ref, second_entry = _compile_visual_material( + "object", cfg.replace(base_color=[0, 0, 1, 1]) + ) + repeated_ref, _ = _compile_visual_material("object", cfg.copy()) + scene = _material_manager()._spawn_scene + scene.builder.materials.update(scene._resolve_materials(dict([first_entry]))) + scene.builder.materials.update(scene._resolve_materials(dict([second_entry]))) + + assert first_ref != second_ref + assert repeated_ref == first_ref + assert len(scene.builder.materials) == 2 + + +def test_deterministic_group_reset_reapplies_properties_only_to_selected_environments(): + material = VisualMaterial( + VisualMaterialCfg(uid="fixed", roughness=0.2), _native_template() + ) + group = RigidObjectGroup.__new__(RigidObjectGroup) + group.uid = "group" + group._entities = [[MagicMock()], [MagicMock()]] + group._selected_indices = lambda env_ids: (env_ids, None, None) + sim = SimpleNamespace( + get_rigid_object_group_uid_list=lambda: ["group"], + get_rigid_object_group=lambda uid: group, + create_visual_material=lambda cfg: material, + ) + env = SimpleNamespace(sim=sim, num_envs=2) + set_rigid_object_group_visual_material( + env, None, SceneEntityCfg(uid="group"), material.cfg + ) + first = material.get_instance("fixed_group_0") + second = material.get_instance("fixed_group_1") + first.set_roughness(0.9) + second.set_roughness(0.9) + + set_rigid_object_group_visual_material( + env, torch.tensor([1]), SceneEntityCfg(uid="group"), material.cfg + ) + + assert first.roughness == pytest.approx(0.9) + assert second.roughness == pytest.approx(0.2) + + +def test_preset_inheritance_and_yaml_roundtrip_keep_explicit_defaults(): + import yaml + from embodichain.lab.sim.material import _material_desc_from_cfg + from embodichain.lab.sim.shapes import ShapeCfg + + cfg = VisualMaterialCfg.from_preset("GlassClear", uid="glass") + assert cfg.ior is None + explicit = cfg.replace(ior=1.5, roughness=0.0) + explicit.parameters = {"thin_walled": False, "transmission": 0.0} + restored = VisualMaterialCfg.from_dict( + yaml.safe_load(yaml.safe_dump(explicit.to_dict())) + ) + assert restored.to_dict() == explicit.to_dict() + shape = ShapeCfg.from_dict( + yaml.safe_load( + yaml.safe_dump( + { + "shape_type": "Sphere", + "radius": 0.25, + "visual_material": explicit.to_dict(), + } + ) + ) + ) + assert shape.visual_material.to_dict() == explicit.to_dict() + assert restored.copy().to_dict() == explicit.to_dict() + assert restored.replace(ior=None).ior is None + assert restored.replace(ior=None).roughness == 0.0 + inherited_desc = _material_desc_from_cfg(cfg, "glass") + explicit_desc = _material_desc_from_cfg(explicit, "glass") + assert inherited_desc != explicit_desc + assert VisualMaterialCfg.from_dict({"preset": "GlassClear"}).ior is None + assert VisualMaterialCfg().ior == 1.5 + assert VisualMaterialCfg().roughness == 0.7 + assert "material_type" not in VisualMaterialCfg().to_dict() + assert ( + _material_desc_from_cfg(VisualMaterialCfg.from_dict(cfg.to_dict()), "glass") + == inherited_desc + ) + + +@pytest.mark.parametrize( + "properties", + [ + {"ior": 1.5, "parameters": {"ior": 1.6}}, + {"emissive_intensity": 0.0, "parameters": {"emission_intensity": 1.0}}, + {"parameters": {"emission_intensity": 1.0, "emissive_intensity": 2.0}}, + {"parameters": {"thin_walled": 0}}, + {"parameters": {"volume_attenuation_distance": 0.0}}, + {"parameters": {"iridescence_thickness": [520, 250]}}, + {"parameters": {"transmission": float("nan")}}, + {"parameters": {"transmission": None}}, + {"parameters": {"misspelled": 1.0}}, + {"ior": 0.0}, + {"roughness": 1.1}, + {"base_color_texture": "override.png"}, + ], +) +def test_preset_invalid_overrides_fail_before_download(properties): + with pytest.raises(ValueError): + VisualMaterialCfg.from_preset("GlassClear", **properties) + + +@pytest.mark.parametrize("preset", [None, "GlassClear"]) +def test_removed_scattering_type_is_rejected_without_silent_conversion(preset): + with pytest.raises(TypeError, match="material_type"): + VisualMaterialCfg(preset=preset, material_type="BSDF") + for legacy in (None, "BRDF", "BTDF", "BSDF"): + with pytest.raises(ValueError, match="material_type has been removed"): + VisualMaterialCfg.from_dict({"preset": preset, "material_type": legacy}) + if preset is not None: + with pytest.raises(TypeError, match="material_type"): + VisualMaterialCfg.from_preset(preset, material_type="BSDF") + + +def test_manual_optical_parameters_load_without_an_explicit_scattering_type(): + import json + from pathlib import Path + from embodichain.lab.sim._material_presets import _MaterialResources + from embodichain.lab.sim.material import _material_desc_from_cfg + + cfg = VisualMaterialCfg( + uid="ice", + base_color=[1, 1, 1, 1], + ior=1.31, + roughness=0.2, + parameters={"transmission": 1.0}, + ) + _, (_, descriptor) = _compile_visual_material("ice_object", cfg) + assert descriptor == _material_desc_from_cfg(cfg, "ice") + world = MagicMock() + template = _native_template() + world.get_env.return_value.load_material.return_value = template + resources = _MaterialResources(world, "rt") + try: + resources.resolve(descriptor) + directory = Path(world.get_env.return_value.load_material.call_args.args[0]) + manifest = json.loads((directory / "material.json").read_text()) + assert manifest["parameters"]["transmission"] == 1.0 + assert manifest["parameters"]["ior"] == 1.31 + assert manifest["parameters"]["roughness"] == 0.2 + template.get_inst().update_pbr_material_type.assert_not_called() + assert "material_type" not in cfg.to_dict() + finally: + resources.close() + + +def test_resource_wrapper_preserves_authored_template_and_resets_full_instance(): + template = _native_template() + default = template.get_inst() + default.set_pbr_param("ior", 1.52) + child = template.get_inst("private_child") + child.set_pbr_param("ior", 1.52) + template.reset_mock() + cfg = VisualMaterialCfg.from_preset("GlassClear", uid="glass") + material = VisualMaterial(cfg, template, initialize_default=False, mat_inst=child) + assert material.inst.mat is child + child.set_roughness.assert_not_called() + child.update_pbr_material_type.assert_not_called() + assert default.get_ior() == pytest.approx(1.52) + material.set_default_properties(material.inst, cfg) + template.reset_inst.assert_called_once_with(child) + with pytest.raises(ValueError, match="original definition"): + material.set_default_properties(material.inst, cfg.replace(ior=1.6)) + + +def test_preset_backend_preflight_and_no_render_declarations(): + from embodichain.lab.sim._material_presets import _MaterialResources + from embodichain.lab.sim.material import _material_desc_from_cfg + + manager = _material_manager() + scene = manager._spawn_scene + world = MagicMock() + scene._material_resources = _MaterialResources(world, "hybrid") + scene._resource_material_definitions = {} + glass = _material_desc_from_cfg( + VisualMaterialCfg.from_preset("GlassClear"), "glass" + ) + with pytest.raises(ValueError, match="OfflineRT"): + scene._resolve_materials({"glass": glass}) + advanced = _material_desc_from_cfg( + VisualMaterialCfg.from_preset("BrickRed", parameters={"clearcoat": 0.0}), + "brick", + ) + with pytest.raises(ValueError, match="OfflineRT"): + scene._resolve_materials({"brick": advanced}) + world.get_env.assert_not_called() + scene._material_resources.renderer = "no-render" + assert scene._resolve_materials({"glass": glass}) == {} + assert scene._material_definition("glass") == glass + world.get_env.assert_not_called() + scene._material_resources.close() + + +def test_native_manifest_variants_share_images_and_templates_without_mutation( + tmp_path, monkeypatch +): + import json + from pathlib import Path + from embodichain.lab.sim._material_presets import _MaterialResources + from embodichain.lab.sim.material import _material_desc_from_cfg + + source = tmp_path / "Material/GlassClear" + source.mkdir(parents=True) + (source / "Color.png").write_bytes(b"shared original image") + manifest = { + "schema_version": 1, + "parameters": { + "ior": 1.52, + "transmission": 1.0, + "volume_attenuation_color": [0.995, 0.998, 1.0], + "iridescence_thickness": [250, 520], + }, + "textures": {"base_color": "Color.png"}, + } + original = json.dumps(manifest) + (source / "material.json").write_text(original) + dataset = SimpleNamespace(get_material_path=lambda name: str(source)) + monkeypatch.setattr( + "embodichain.data.assets.materials.DefaultMaterials", lambda: dataset + ) + world = MagicMock() + world.get_env.return_value.load_material.side_effect = ( + lambda *args: _native_template() + ) + resources = _MaterialResources(world, "rt") + cfg = VisualMaterialCfg.from_preset( + "GlassClear", + ior=1.5, + parameters={"volume_attenuation_distance": 8.0, "thin_walled": False}, + ) + try: + first = resources.resolve(_material_desc_from_cfg(cfg, "one")) + second = resources.resolve(_material_desc_from_cfg(cfg, "two")) + assert first[0] is second[0] + assert first[1] is not second[1] + assert resources.resolve(_material_desc_from_cfg(cfg, "one")) is first + world.get_env.return_value.load_material.assert_called_once() + directory = Path(world.get_env.return_value.load_material.call_args.args[0]) + staged = json.loads((directory / "material.json").read_text()) + assert staged["parameters"] == { + **manifest["parameters"], + "ior": 1.5, + "volume_attenuation_distance": 8.0, + "thin_walled": False, + } + assert Path(staged["textures"]["base_color"]) == source / "Color.png" + assert list(directory.iterdir()) == [directory / "material.json"] + assert (source / "material.json").read_text() == original + finally: + resources.close() + assert not directory.exists() + + +def test_common_parameter_aliases_canonicalize_before_replace_restores_inheritance(): + from embodichain.lab.sim.material import _material_desc_from_cfg + + cfg = VisualMaterialCfg.from_dict( + { + "preset": "GlassClear", + "uid": "glass", + "parameters": {"ior": 1.5, "emission_intensity": 0.0, "thin_walled": False}, + } + ) + expected = VisualMaterialCfg.from_preset( + "GlassClear", + uid="glass", + ior=1.5, + emissive_intensity=0.0, + parameters={"thin_walled": False}, + ) + assert cfg.to_dict() == expected.to_dict() + inherited = cfg.replace(ior=None, emissive_intensity=None) + assert inherited.ior is None and inherited.emissive_intensity is None + assert inherited.parameters == {"thin_walled": False} + assert _material_desc_from_cfg(inherited, "glass") == _material_desc_from_cfg( + VisualMaterialCfg.from_preset("GlassClear", parameters={"thin_walled": False}), + "glass", + ) + + +def test_builtin_presets_resolve_without_resource_download_and_keep_overrides( + monkeypatch, +): + import json + from pathlib import Path + from embodichain.data import get_default_material_info + from embodichain.lab.sim._material_presets import _MaterialResources + from embodichain.lab.sim.material import _material_desc_from_cfg + + def forbidden(*args, **kwargs): + raise AssertionError("Built-in presets must not construct DefaultMaterials") + + monkeypatch.setattr("embodichain.data.assets.materials.DefaultMaterials", forbidden) + world = MagicMock() + world.get_env.return_value.load_material.side_effect = ( + lambda *args: _native_template() + ) + resources = _MaterialResources(world, "rt") + cfg = VisualMaterialCfg.from_preset("PlasticMatteBlue", roughness=0.0) + try: + resources.resolve(_material_desc_from_cfg(cfg, "blue")) + path = ( + Path(world.get_env.return_value.load_material.call_args.args[0]) + / "material.json" + ) + manifest = json.loads(path.read_text()) + assert manifest["parameters"]["base_color"] == [0.05, 0.22, 0.6, 1] + assert manifest["parameters"]["roughness"] == 0.0 + assert manifest["parameters"]["transmission"] == 0.0 + assert manifest["textures"] == {} + assert ( + get_default_material_info("PlasticMatteBlue")["parameters"]["roughness"] + == 0.65 + ) + resources.resolve( + _material_desc_from_cfg(cfg.replace(roughness=None), "inherited") + ) + path = ( + Path(world.get_env.return_value.load_material.call_args.args[0]) + / "material.json" + ) + assert json.loads(path.read_text())["parameters"]["roughness"] == 0.65 + assert resources._dataset is None + finally: + resources.close() diff --git a/tests/sim/test_material_integration.py b/tests/sim/test_material_integration.py new file mode 100644 index 0000000000..3046b8512a --- /dev/null +++ b/tests/sim/test_material_integration.py @@ -0,0 +1,356 @@ +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +"""Native material parity and restore/rebuild qualification for DexSim 0.5.1rc1.""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from types import SimpleNamespace + +from embodichain.lab.sim import ( + SimulationManager, + SimulationManagerCfg, + VisualMaterialCfg, +) +from embodichain.lab.sim.cfg import ( + RenderCfg, + RigidObjectCfg, + VisualObjectCfg, + physics_cfg_for_backend, +) +from embodichain.lab.sim.shapes import CubeCfg +from embodichain.lab.gym.envs.managers import FunctorCfg +from embodichain.lab.gym.envs.managers.cfg import SceneEntityCfg +from embodichain.lab.gym.envs.managers.randomization.visual import ( + randomize_visual_material, +) + +pytestmark = [pytest.mark.requires_sim, pytest.mark.renderer] + + +@pytest.mark.parametrize("physics", ["default", "newton"]) +def test_native_material_creation_reset_and_topology_rebuild(physics): + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + device="cpu", + num_envs=2, + scene_node_capacity=128, + physics_cfg=physics_cfg_for_backend(physics), + render_cfg=RenderCfg(renderer="hybrid"), + ) + ) + obj = other = runtime = None + cfg = VisualMaterialCfg( + base_color=[0.8, 0.1, 0.2, 1.0], + metallic=0.3, + roughness=0.2, + emissive=[0.1, 0.2, 0.3], + emissive_intensity=2.0, + ior=1.7, + ) + try: + obj = sim.add_rigid_object( + RigidObjectCfg( + uid="red", + body_type="static", + init_pos=(0.0, 0.0, 0.5), + shape=CubeCfg(size=(0.1, 0.1, 0.1), visual_material=cfg), + ) + ) + other = sim.add_visual_object( + VisualObjectCfg( + uid="blue", + shape=CubeCfg( + size=(0.1, 0.1, 0.1), + visual_material=VisualMaterialCfg(base_color=[0.1, 0.2, 0.8, 1.0]), + ), + ) + ) + sim.prepare() + initial_names = [m.mat.get_name() for m in obj.get_visual_material_inst()] + assert obj.get_visual_material_inst()[0].base_color == pytest.approx( + cfg.base_color[:3], abs=1e-5 + ) + assert other.get_visual_material_inst()[0].base_color == pytest.approx( + [0.1, 0.2, 0.8], abs=1e-5 + ) + + runtime = sim.create_visual_material(cfg.replace(uid="runtime")) + shared_visual = sim.add_visual_object( + VisualObjectCfg( + uid="runtime_copy", + shape=CubeCfg( + size=(0.1, 0.1, 0.1), visual_material=cfg.replace(uid="runtime") + ), + ) + ) + assert shared_visual.get_visual_material_inst()[0].base_color == pytest.approx( + cfg.base_color[:3], abs=1e-5 + ) + assert sim.remove_asset("runtime_copy") + del shared_visual + borrowed_cfg = VisualMaterialCfg(uid="borrowed_state", roughness=0.2) + borrowed_visual = sim.add_visual_object( + VisualObjectCfg( + uid="borrowed_visual", + shape=CubeCfg(size=(0.1, 0.1, 0.1), visual_material=borrowed_cfg), + ) + ) + native_template = sim.get_env().find_material("borrowed_state") + native_template.get_inst().set_roughness(0.8) + borrowed = sim.create_visual_material(borrowed_cfg) + assert borrowed.inst.roughness == pytest.approx(0.8) + assert native_template.get_inst().get_roughness() == pytest.approx(0.8) + assert sim.remove_asset("borrowed_visual") + del borrowed, borrowed_visual, native_template + parsed = obj.get_visual_material_inst()[0] + assert parsed.roughness == pytest.approx(runtime.inst.roughness) + assert parsed.metallic == pytest.approx(runtime.inst.metallic) + assert parsed.ior == pytest.approx(runtime.inst.ior) + np.testing.assert_allclose( + parsed.mat.get_emissive(), runtime.inst.mat.get_emissive(), atol=1e-5 + ) + del parsed + + obj.set_visual_material(runtime, [1]) + selected = obj.get_visual_material_inst([1])[0] + selected.set_roughness(0.6) + assert selected.mat.get_roughness() == pytest.approx(0.6) + assert runtime.get_instance(selected.uid) is selected + del selected + obj.restore_visual_material([1]) + + # Adding/removing a different asset rebuilds Newton's native render resources. + extra = sim.add_visual_object( + VisualObjectCfg(uid="extra", shape=CubeCfg(size=(0.1, 0.1, 0.1))) + ) + assert [ + m.mat.get_name() for m in obj.get_visual_material_inst() + ] == initial_names + assert [ + entity.get_render_body().get_material(0).get_name() + for entity in obj._entities + ] == initial_names + assert sim.remove_asset("extra") + del extra + sim.prepare() + assert [ + entity.get_render_body().get_material(0).get_name() + for entity in obj._entities + ] == initial_names + + # A current, intentional replacement must also survive a later rebuild. + obj.set_visual_material(runtime, [1]) + selected_name = obj.get_visual_material_inst([1])[0].mat.get_name() + assert sim.remove_asset("blue") + sim.prepare() + assert ( + obj._entities[0].get_render_body().get_material(0).get_name() + == initial_names[0] + ) + assert ( + obj._entities[1].get_render_body().get_material(0).get_name() + == selected_name + ) + obj.reset([1]) + assert ( + obj._entities[1].get_render_body().get_material(0).get_name() + == initial_names[1] + ) + + # Repeated partial randomization retains native instance capacity and the other row. + event_cfg = FunctorCfg(func=randomize_visual_material) + event_cfg.params = { + "entity_cfg": SceneEntityCfg(uid="red"), + "p_solid": 1.0, + "solid_texture_count": 2, + "metallic_range": (0.9, 0.9), + "roughness_range": (0.2, 0.2), + "ior_range": (2.0, 2.0), + } + env = SimpleNamespace(sim=sim, num_envs=2, device=torch.device("cpu")) + randomizer = randomize_visual_material(event_cfg, env) + selected_ids = torch.tensor([1]) + randomizer(env, selected_ids, **event_cfg.params) + template_counts = [ + segments[0].working_inst.mat.get_template().inst_count() + for segments in randomizer._reuse_state + ] + for _ in range(1025): + randomizer(env, selected_ids, **event_cfg.params) + assert [ + segments[0].working_inst.mat.get_template().inst_count() + for segments in randomizer._reuse_state + ] == template_counts + assert obj.get_visual_material_inst([1])[0].metallic == pytest.approx(0.9) + assert obj.get_visual_material_inst([1])[0].roughness == pytest.approx(0.2) + assert obj.get_visual_material_inst([1])[0].ior == pytest.approx(2.0) + assert ( + obj._entities[0].get_render_body().get_material(0).get_name() + == initial_names[0] + ) + del randomizer, env + + replacement_cfg = RigidObjectCfg( + uid="red", + body_type="static", + init_pos=(0.0, 0.0, 0.5), + shape=CubeCfg( + size=(0.1, 0.1, 0.1), + visual_material=VisualMaterialCfg(base_color=[0.1, 0.2, 0.8, 1.0]), + ), + ) + obj = sim.replace_rigid_object(replacement_cfg) + assert obj.get_visual_material_inst()[0].base_color == pytest.approx( + [0.1, 0.2, 0.8], abs=1e-5 + ) + definition_count = len(sim._spawn_scene.builder.materials) + obj = sim.replace_rigid_object(replacement_cfg.copy()) + assert len(sim._spawn_scene.builder.materials) == definition_count + assert sim.remove_asset("red") + obj = sim.add_rigid_object( + replacement_cfg.replace( + shape=CubeCfg( + size=(0.1, 0.1, 0.1), + visual_material=VisualMaterialCfg(base_color=[0.1, 0.8, 0.2, 1.0]), + ) + ) + ) + assert obj.get_visual_material_inst()[0].base_color == pytest.approx( + [0.1, 0.8, 0.2], abs=1e-5 + ) + finally: + sim.destroy(exit_process=False) + del obj, other, runtime + SimulationManager.flush_cleanup_queue() + + +@pytest.mark.parametrize("physics", ["default", "newton"]) +@pytest.mark.parametrize("source", ["preset", "manual"]) +def test_native_parameter_material_identity_reset_and_rebuild(physics, source): + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + device="cpu", + num_envs=2, + scene_node_capacity=128, + physics_cfg=physics_cfg_for_backend(physics), + render_cfg=RenderCfg(renderer="hybrid" if source == "preset" else "rt"), + ) + ) + cfg = ( + VisualMaterialCfg.from_preset("BrickRed", uid="brick", ior=1.6, roughness=0.25) + if source == "preset" + else VisualMaterialCfg( + uid="brick", + base_color=[1, 1, 1, 1], + ior=1.6, + roughness=0.25, + parameters={"transmission": 0.75}, + ) + ) + obj = runtime = other = child = extra = selected = alias = None + try: + # Inline first, then runtime creation borrows the exact current handle. + obj = sim.add_rigid_object( + RigidObjectCfg( + uid="brick_shape", + body_type="static", + init_pos=(0, 0, 0.5), + shape=CubeCfg(size=(0.1, 0.1, 0.1), visual_material=cfg), + ) + ) + sim.prepare() + original_ids = [m.mat.get_material_id() for m in obj.get_visual_material_inst()] + assert obj.get_visual_material_inst()[0].ior == pytest.approx(1.6) + obj.get_visual_material_inst()[0].set_roughness(0.55) + runtime = sim.create_visual_material(cfg) + assert runtime.inst.mat.get_material_id() == original_ids[0] + assert runtime.inst.roughness == pytest.approx(0.55) + # Equivalent UIDs share immutable native templates, with separate children. + other = sim.create_visual_material(cfg.replace(uid="other_brick")) + assert other.inst.mat.get_material_id() != runtime.inst.mat.get_material_id() + assert other.inst.roughness == pytest.approx(0.25) + assert runtime.mat.get_inst().get_roughness() == pytest.approx(0.25) + assert ( + other.mat.get_inst().get_material_id() + == runtime.mat.get_inst().get_material_id() + ) + child = runtime.create_instance("other_brick") + assert child.mat.get_material_id() != other.inst.mat.get_material_id() + child.set_ior(2.0) + child.mat.set_transmission(0.25) + assert child.mat.get_transmission() == pytest.approx(0.25) + runtime.set_default_properties(child, cfg) + assert child.ior == pytest.approx(1.6) + if source == "preset": + # The legacy resource template leaves transmission unset. + assert child.mat.get_transmission() is None + else: + assert child.mat.get_transmission() == pytest.approx(0.75) + count = runtime.mat.inst_count() + for _ in range(1025): + assert runtime.create_instance("other_brick") is child + assert sim.create_visual_material(cfg) is runtime + assert runtime.mat.inst_count() == count + with pytest.raises(ValueError, match="Conflicting visual material"): + sim.create_visual_material(cfg.replace(ior=1.7)) + # Runtime first, then a Spawn declaration binds that exact child. + alias = sim.add_visual_object( + VisualObjectCfg( + uid="runtime_alias", + shape=CubeCfg( + size=(0.1, 0.1, 0.1), visual_material=cfg.replace(uid="other_brick") + ), + ) + ) + assert ( + alias.get_visual_material_inst()[0].mat.get_material_id() + == other.inst.mat.get_material_id() + ) + obj.set_visual_material(other, [1]) + selected = obj.get_visual_material_inst([1])[0] + assert ( + other.get_instance(selected.uid).mat.get_material_id() + == selected.mat.get_material_id() + ) + selected_id = selected.mat.get_material_id() + extra = sim.add_visual_object( + VisualObjectCfg(uid="extra_preset", shape=CubeCfg(size=(0.1, 0.1, 0.1))) + ) + assert obj.get_visual_material_inst([1])[0].mat.get_material_id() == selected_id + obj.reset([1]) + assert [ + m.mat.get_material_id() for m in obj.get_visual_material_inst() + ] == original_ids + assert sim.remove_asset("extra_preset") + sim.prepare() + assert [ + m.mat.get_material_id() for m in obj.get_visual_material_inst() + ] == original_ids + # Native cleanup preserves reserved definitions and live resource state. + sim.clean_materials() + runtime = sim.create_visual_material(cfg) + assert runtime.inst.roughness == pytest.approx(0.55) + assert runtime.inst.mat.get_material_id() == original_ids[0] + finally: + sim.destroy(exit_process=False) + del obj, runtime, other, child, extra, selected, alias + SimulationManager.flush_cleanup_queue() diff --git a/tests/test_main.py b/tests/test_main.py index 775e0d5dfb..3a97dac048 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -45,6 +45,7 @@ "show-task", "preview-asset", "preview-robot", + "preview-material", "preview_lerobot_data", "run-task", "run-example",