Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/pymat/vis/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,44 @@ def resolve(self, channel: str, scalar: float | None = None) -> ResolvedChannel:
tex = self.textures.get(channel)
return ResolvedChannel(texture=tex, scalar=scalar)

# ── Adapter sugar (delegate to module-level, method discoverability) ─

def to_threejs(self) -> dict[str, Any]:
"""Shorthand for ``pymat.vis.to_threejs(material)`` — method form.

Same output as the module-level adapter. Use this when you have
a ``Vis`` in hand (``material.vis.to_threejs()``); use the
module-level form (``pymat.vis.to_threejs(material)``) in code
that's explicitly function-composition oriented.
"""
from pymat.vis.adapters import to_threejs

return to_threejs(self)

def to_gltf(self, *, name: str | None = None) -> dict[str, Any]:
"""Shorthand for ``pymat.vis.to_gltf(material)`` — method form.

The glTF material node's ``name`` field is populated from
``name=`` if given, else left empty (the method has no
visibility into the owning Material's name; pass it through
explicitly when calling on a standalone Vis). The module-level
``pymat.vis.to_gltf(material)`` reads ``material.name``
automatically.
"""
from pymat.vis.adapters import to_gltf

return to_gltf(self, name=name)

def export_mtlx(self, output_dir: str | Path, *, name: str | None = None) -> Path:
"""Shorthand for ``pymat.vis.export_mtlx(material, out)``.

``name=`` sets the MTLX filename stem. Omitted on a standalone
Vis → defaults to ``"material"``.
"""
from pymat.vis.adapters import export_mtlx

return export_mtlx(self, Path(output_dir), name=name)

# ── Discovery (py-mat's tag-aware layer over client.search) ─

def discover(
Expand Down
76 changes: 54 additions & 22 deletions src/pymat/vis/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,31 @@
The actual format logic (Three.js field names, glTF schema,
MaterialX XML) lives in mat_vis_client.adapters (installed from
mat-vis-client package). These wrappers extract scalars + textures
from a Material and pass them through.
from a ``Material`` (or a standalone ``Vis``) and pass them through.

from pymat.vis.adapters import to_threejs, to_gltf, export_mtlx
result = to_threejs(material)
result = to_threejs(material) # Material form
result = to_threejs(material.vis) # Vis form — same output

The polymorphism lets ``Vis.to_gltf()`` / ``Vis.to_threejs()`` method
sugar delegate here without a back-reference from ``Vis`` to its
owning ``Material``.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Union

from mat_vis_client.adapters import export_mtlx as _export_mtlx
from mat_vis_client.adapters import to_gltf as _to_gltf
from mat_vis_client.adapters import to_threejs as _to_threejs

if TYPE_CHECKING:
from pymat.core import _MaterialInternal as Material
from pymat.vis._model import Vis

MaterialOrVis = Union[Material, Vis]


def _rgba_to_hex(rgba: list[float] | tuple[float, ...] | None) -> str | None:
Expand All @@ -32,14 +40,23 @@ def _rgba_to_hex(rgba: list[float] | tuple[float, ...] | None) -> str | None:
return f"#{r:02x}{g:02x}{b:02x}"


def _extract_scalars(material: Material) -> dict[str, Any]:
"""Extract PBR scalars from material.vis with defaults.
def _resolve_vis_and_name(obj: MaterialOrVis) -> tuple[Vis, str]:
"""Unwrap a Material (``→ .vis, .name``) or a standalone Vis
(``→ self, ""``). Duck-typed via the ``.vis`` attribute: anything
that exposes ``.vis`` is treated as the owning Material."""
if hasattr(obj, "vis"):
return obj.vis, getattr(obj, "name", "") or ""
return obj, "" # assume it's a Vis


def _extract_scalars(obj: MaterialOrVis) -> dict[str, Any]:
"""Extract PBR scalars from material.vis (or a plain Vis).

Maps py-mat "metallic" → mat-vis "metalness" and our RGBA
base_color list → mat-vis's color_hex string (its adapters
only know how to emit color from the hex form).
"""
vis = material.vis
vis, _ = _resolve_vis_and_name(obj)
return {
"metalness": vis.get("metallic"),
"roughness": vis.get("roughness"),
Expand All @@ -51,41 +68,56 @@ def _extract_scalars(material: Material) -> dict[str, Any]:
}


def _extract_textures(material: Material) -> dict[str, bytes]:
"""Extract texture bytes from Material.vis."""
if not material.vis.has_mapping:
def _extract_textures(obj: MaterialOrVis) -> dict[str, bytes]:
"""Extract texture bytes from a Material's Vis (or a plain Vis)."""
vis, _ = _resolve_vis_and_name(obj)
if not vis.has_mapping:
return {}
return material.vis.textures
return vis.textures


def to_threejs(material: Material) -> dict[str, Any]:
def to_threejs(obj: MaterialOrVis) -> dict[str, Any]:
"""Format as a Three.js MeshPhysicalMaterial-compatible dict.

Reads PBR scalars and texture maps from material.vis.
Delegates to mat-vis's generic adapter.
Accepts either a ``Material`` or a standalone ``Vis``. Reads PBR
scalars and texture maps from ``obj.vis`` (or from ``obj`` itself
if it's a Vis). Delegates to mat-vis's generic adapter.
"""
return _to_threejs(_extract_scalars(material), _extract_textures(material))
return _to_threejs(_extract_scalars(obj), _extract_textures(obj))


def to_gltf(material: Material) -> dict[str, Any]:
def to_gltf(obj: MaterialOrVis, *, name: str | None = None) -> dict[str, Any]:
"""Format as a glTF pbrMetallicRoughness material dict.

Accepts either a ``Material`` (its ``.name`` is used as the glTF
material ``name`` field) or a standalone ``Vis`` (pass ``name=``
explicitly to populate the field; empty string otherwise).
Delegates to mat-vis's generic adapter.
"""
result = _to_gltf(_extract_scalars(material), _extract_textures(material))
result["name"] = material.name
_, resolved_name = _resolve_vis_and_name(obj)
result = _to_gltf(_extract_scalars(obj), _extract_textures(obj))
result["name"] = name if name is not None else resolved_name
return result


def export_mtlx(material: Material, output_dir: Path) -> Path:
def export_mtlx(
obj: MaterialOrVis,
output_dir: Path,
*,
name: str | None = None,
) -> Path:
"""Export as a MaterialX .mtlx file + PNG textures on disk.

Delegates to mat-vis's generic adapter.
Accepts either a ``Material`` (its ``.name`` becomes the filename
stem) or a standalone ``Vis`` (pass ``name=`` explicitly to name
the output). Delegates to mat-vis's generic adapter.
"""
safe_name = material.name.replace(" ", "_").replace("/", "_")
_, resolved_name = _resolve_vis_and_name(obj)
mat_name = name if name is not None else resolved_name
safe_name = mat_name.replace(" ", "_").replace("/", "_") or "material"
return _export_mtlx(
_extract_scalars(material),
_extract_textures(material),
_extract_scalars(obj),
_extract_textures(obj),
output_dir,
material_name=safe_name,
)
159 changes: 159 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,162 @@ def test_export_no_textures(self):
assert mtlx_path.exists()
pngs = list(Path(tmp).glob("*.png"))
assert len(pngs) == 0 # no textures


class TestAdapterPolymorphism:
"""Adapters accept either a Material or a Vis — same output.

This is the property that lets ``Vis.to_gltf()`` method sugar
delegate to the module-level adapter without a back-reference
from Vis to its owning Material.
"""

def test_to_threejs_material_and_vis_agree(self):
m = _make_material()
assert to_threejs(m) == to_threejs(m.vis)

def test_to_gltf_material_and_vis_agree_on_pbr(self):
m = _make_material()
from_material = to_gltf(m)
from_vis = to_gltf(m.vis, name=m.name)
assert from_material == from_vis

def test_to_gltf_vis_without_name_leaves_empty_string(self):
m = _make_material()
d = to_gltf(m.vis) # no name= kwarg
assert d["name"] == ""

def test_export_mtlx_vis_accepts_name_kwarg(self):
m = _make_material(with_vis=True)
with tempfile.TemporaryDirectory() as tmp:
# Via Vis + explicit name — filename stem must match.
mtlx_path = export_mtlx(m.vis, Path(tmp), name="Stem")
assert mtlx_path.exists()
assert mtlx_path.stem == "Stem", f"expected stem 'Stem', got {mtlx_path.stem!r}"

def test_export_mtlx_vis_without_name_falls_back(self):
"""A standalone Vis with no name= kwarg must not crash; the
default stem is whatever the adapter picks (currently 'material')."""
from pymat.vis._model import Vis

# Construct a Vis fully detached from any Material — this is
# the path a downstream like mat-vis-client might hit if they
# adopted the same sugar independently.
vis = Vis(source=None, material_id=None) # no mapping → no textures
with tempfile.TemporaryDirectory() as tmp:
mtlx_path = export_mtlx(vis, Path(tmp))
assert mtlx_path.exists()
assert mtlx_path.stem != "", "empty stem would break filesystem writes"

def test_resolve_vis_duck_typing_invariant(self):
"""``_resolve_vis_and_name`` distinguishes Material-vs-Vis via
``hasattr(obj, "vis")``. If Vis ever grows a ``.vis`` attribute
(self-reference, alias, anything), the helper would recurse and
misclassify. Pin that invariant here."""
from pymat.vis._model import Vis

vis = Vis(source="ambientcg", material_id="Metal032")
assert not hasattr(vis, "vis"), (
"Vis must not have a .vis attribute — would break the "
"Material-vs-Vis duck-typing in adapter._resolve_vis_and_name. "
"If this test starts failing, the helper needs an "
"isinstance check instead of hasattr."
)

def test_adapter_name_is_keyword_only(self):
"""``to_gltf(obj, name=...)`` must be keyword-only. Positional
call should be a TypeError — prevents a downstream relying on
positional ordering that could silently shift on a future
signature change."""
import pytest

from pymat.vis._model import Vis

vis = Vis()
with pytest.raises(TypeError):
to_gltf(vis, "positional_name") # type: ignore[misc]


class TestAdapterOnDetachedVis:
"""Full-path tests that construct a Vis without any Material,
verifying every adapter still works on the raw payload object.
This is the contract downstreams (including a hypothetical
mat-vis-client VisAsset) can rely on."""

def _detached_vis_with_scalars(self):
from pymat.vis._model import Vis

v = Vis()
v.metallic = 1.0
v.roughness = 0.25
v.base_color = (0.9, 0.9, 0.9, 1.0)
v.ior = 1.5
v.transmission = 0.0
return v

def test_to_threejs_on_detached_vis(self):
v = self._detached_vis_with_scalars()
d = to_threejs(v)
assert d["metalness"] == 1.0
assert d["roughness"] == 0.25
assert "ior" in d # ior scalar routed through

def test_to_gltf_on_detached_vis_empty_name(self):
v = self._detached_vis_with_scalars()
d = to_gltf(v)
assert d["pbrMetallicRoughness"]["metallicFactor"] == 1.0
assert d["name"] == "" # no owning Material, no name kwarg

def test_to_gltf_on_detached_vis_with_name(self):
v = self._detached_vis_with_scalars()
d = to_gltf(v, name="Hand-rolled")
assert d["name"] == "Hand-rolled"

def test_vis_method_on_detached_vis(self):
"""The method form must also work on a detached Vis — covers
the common case of ``Vis().to_gltf(name='...')`` in downstream
code that constructs Vis without a py-mat Material."""
v = self._detached_vis_with_scalars()
assert v.to_gltf(name="X") == to_gltf(v, name="X")
assert v.to_threejs() == to_threejs(v)

def test_method_call_surface_present(self):
"""``m.vis.<TAB>`` must surface the three adapter methods —
that's the whole point of this sugar."""
v = self._detached_vis_with_scalars()
for name in ("to_threejs", "to_gltf", "export_mtlx"):
attr = getattr(v, name, None)
assert attr is not None, f"Vis.{name} missing"
assert callable(attr), f"Vis.{name} not callable"


class TestVisAdapterMethods:
"""Method-form sugar on Vis — discoverability via tab completion.

Must be a drop-in shorthand: ``m.vis.to_gltf()`` and
``pymat.vis.to_gltf(m)`` must produce the same dict. Same for
``to_threejs`` and ``export_mtlx``.
"""

def test_vis_to_threejs_matches_module_level(self):
m = _make_material()
assert m.vis.to_threejs() == to_threejs(m)

def test_vis_to_gltf_matches_module_level_with_name(self):
m = _make_material()
assert m.vis.to_gltf(name=m.name) == to_gltf(m)

def test_vis_to_gltf_no_name_leaves_empty(self):
"""Vis.to_gltf() without name= produces an empty name field —
the method can't reach the owning Material without a back-ref,
so users on a standalone Vis path opt in by passing name="X"."""
m = _make_material()
d = m.vis.to_gltf()
assert d["name"] == ""

def test_vis_export_mtlx_delegates(self):
m = _make_material(with_vis=True)
with tempfile.TemporaryDirectory() as tmp:
mtlx_path = m.vis.export_mtlx(Path(tmp), name="ViaMethod")
assert mtlx_path.exists()
assert mtlx_path.suffix == ".mtlx"
33 changes: 33 additions & 0 deletions tests/test_e2e_vis.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,39 @@ def test_prefetch_small(self, tmp_path):
textures = vis.fetch(r["source"], r["id"], tier="128")
assert len(textures) >= 0 # may be 0 if 128 tier not available

def test_mtlx_xml_returns_materialx_string(self):
"""Material.vis.mtlx.xml() returns a MaterialX XML string.

Pins the 0.5 method-form (``.xml()`` not property ``.xml``) and
asserts the content is MaterialX — not an error page or empty
string. The full ``.export(path)`` package path is covered by
the adapter test; this test locks the direct ``.xml()`` accessor
the README + build123d integration docs call out.
"""
from pymat import Material, vis

results = vis.search(category="metal", limit=1)
if not results:
pytest.skip("No metal materials in mat-vis index")

source = results[0].get("source", "ambientcg")
mat_id = results[0]["id"]

m = Material(name="mtlx xml probe")
m.vis.source = source
m.vis.material_id = mat_id
m.vis.tier = results[0].get("default_tier") or "1k"

with _skip_on_upstream_outage():
xml = m.vis.mtlx.xml()

assert isinstance(xml, str), f"expected str, got {type(xml).__name__}"
lowered = xml.lower()
assert "<materialx" in lowered or xml.startswith("<?xml"), (
f"not a MaterialX document; first 200 chars: {xml[:200]!r}"
)
assert len(xml) > 200, f"suspiciously short MaterialX doc ({len(xml)} chars)"

def test_resolve_channel(self):
"""Vis.resolve() returns texture or scalar fallback."""
from pymat import Material, vis
Expand Down
Loading
Loading