Source code for embodichain.lab.scripts.preview_material

# ----------------------------------------------------------------------------
# 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"]

_STUDIO_LIGHTING = {
    "exposure": 1.6,
    "environment_intensity": 48.0,
    "key_intensity": 150.0,
    "fill_intensity": 120.0,
}


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=_STUDIO_LIGHTING["exposure"],
                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], _STUDIO_LIGHTING["environment_intensity"]
        )
        # 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),
                _STUDIO_LIGHTING["key_intensity"],
            ),
            (
                "fill",
                (2, 0, 3),
                (-2, 0, -2),
                (0.6, 0.8, 1),
                _STUDIO_LIGHTING["fill_intensity"],
            ),
        ):
            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-v2",
            "lighting": dict(_STUDIO_LIGHTING),
            "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


[docs] 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)
[docs] 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()