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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
".": "3.3.0",
".": "3.4.0",
"mat-rs": "0.2.0"
}
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,61 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.4.0] - 2026-04-20

Unblocks the build123d#1270 Materials-class adapt pass — fixes two bugs
Bernhard reported against 3.3.0 ([#88](https://github.com/MorePET/mat/issues/88), [#89](https://github.com/MorePET/mat/issues/89)) and
adds a runnable, in-CI example file that mirrors his cell-style pattern.

### Added

* **`pymat["Stainless Steel 304"]`** — module-level subscript for exact
lookup. Matches registry key OR `Material.name` OR `grade`
(case-insensitive, NFKC + whitespace-collapse normalization). Raises
`KeyError` with close-match suggestions on miss, raises on ambiguity
with candidate list. `in` operator also supported. Closes [#89](https://github.com/MorePET/mat/issues/89).
* **`pymat.search(q, *, exact=True)`** — list-returning exact variant,
symmetric with the fuzzy form. `exact=True` now matches `grade` too
(previously only key + name — caught by the falsify review).
* **Input normalization on all lookup paths** — `search()` and `pymat[...]`
both apply NFKC + case-fold + whitespace-collapse, so pasted strings
with curly quotes, non-breaking spaces, or tabs resolve cleanly.
* **`examples/build123d_integration.py`** — cell-style (`# %%`) runnable
script showing the full build123d + py-mat integration surface.
Wired into pytest via `tests/test_build123d_integration_examples.py`
so every cell is exercised on every CI run. Prevents API drift from
breaking the downstream copy-paste.

### Fixed

* **Grades inherit parent `Vis` at load time** ([#88](https://github.com/MorePET/mat/issues/88)). Before 3.4, a grade
without its own `[vis]` TOML section landed with `source=None`, which
made `pymat.s304.vis.source` useless for every grade and forced
downstream consumers (build123d#1270) to walk the parent chain
manually. The loader now deep-copies the parent's `_vis` when a grade
has no `[vis]`, and merges TOML overrides on top when it does. Matches
the existing `_add_child` property-inheritance pattern. Runtime mutation
semantics: changes on a child don't propagate to parent (or vice versa)
— documented, intentional.

### Internal

* **`Vis.merge_from_toml(base, vis_data)`** — new classmethod for the
loader's partial-override path. Handles the "grade specifies only
`roughness=0.7`, inherit everything else" case that
`Vis.from_toml` (pure constructor) couldn't express.
* **`search.py`**: new `_normalize()` helper, new `exact=True` path,
grade added to exact-match targets. 25 new tests across
`tests/test_lookup.py` + fuzzy regression coverage.
* **`tests/test_vis_inheritance.py`** — 21 tests pinning inheritance,
isolation (child mutations don't touch parent), `merge_from_toml`
unit coverage, cache-invalidation-on-identity-change, and end-to-end
"Bernhard's workaround no longer needed".
* Falsify review documented at [py-mat#88](https://github.com/MorePET/mat/issues/88)
— original deep-copy-at-access design was refuted by three orthogonal
reviewers; final load-time deep-copy-with-merge design was chosen to
match existing `_add_child` convention.

## [3.3.0] - 2026-04-19

Adds `pymat.search()` — fuzzy find over the domain library.
Expand Down
228 changes: 228 additions & 0 deletions examples/build123d_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""
Cell-style examples for the py-materials + build123d integration.

Each ``# %%`` block is an independent cell — open this file in VS Code
/ Jupyter / PyCharm to step through, or run top-to-bottom as a plain
Python script. The full script is also wired into the test suite via
``tests/test_build123d_integration_examples.py`` so every example is
kept in working condition.

Covers the shape of the API surface that build123d#1270's Materials
class will consume:

- ``pymat["name or key"]`` — exact lookup (#89)
- ``pymat.search(...)`` — fuzzy find (3.3.0)
- ``m.vis.source / .vis.*`` — visual props, inherited by grades (#88)
- ``m.vis.to_threejs()`` — Three.js handoff
- ``m.vis.to_gltf()`` — glTF 2.0 material node
- ``m.vis.mtlx.xml() / .export(dir)`` — MaterialX
- ``shape.material = m`` — build123d wiring
- ``export_gltf(shape, path)``— current baseColor-only path
"""

# %% [markdown]
# # 1. Install + imports
#
# ```
# pip install "py-materials[build123d]>=3.3.0"
# ```

# %%
import pymat

print(f"py-materials version: {pymat.__version__}")


# %% [markdown]
# # 2. Look up a Material by name or key
#
# Three ways to resolve a user-typed string to a `Material`:
#
# | Form | When |
# | --------------------------------- | ------------------------------------- |
# | `pymat["Stainless Steel 304"]` | Exact match — raises if missing/amb. |
# | `pymat.search(q, exact=True)` | Same but returns `list[Material]` |
# | `pymat.search(q)` | Fuzzy — tokenized, ranked list |

# %%
# Exact by name (case + whitespace insensitive, NFKC-normalized)
housing_mat = pymat["Stainless Steel 304"]
assert housing_mat.name == "Stainless Steel 304"
assert housing_mat.grade == "304"

# %%
# Exact by registry key
bolt_mat = pymat["s316L"]
assert bolt_mat.grade == "316L"

# %%
# Exact by grade — grade strings like "304", "6061", "T6" all resolve
crystal_mat = pymat["304"]
assert crystal_mat.name == "Stainless Steel 304"

# %%
# Normalization — user-pasted strings with weird whitespace / case just work
assert pymat[" stainless steel 304 "].name == "Stainless Steel 304"
assert pymat["STAINLESS STEEL 304"].name == "Stainless Steel 304"

# %%
# Unknown or ambiguous raises KeyError with a helpful candidate list
try:
_ = pymat["not-a-real-material"]
except KeyError as e:
print(f"expected miss: {e}")


# %% [markdown]
# # 3. Fuzzy search
#
# When the user query may match multiple materials (or none), use
# `pymat.search(...)` and decide how to present ambiguity.

# %%
hits = pymat.search("Stainless Steel")
print(f"{len(hits)} matches for 'Stainless Steel':")
for m in hits[:5]:
print(f" - {m.name} (key={m._key})")

# %%
# Tokenized fuzzy — every whitespace-token must match somewhere
narrower = pymat.search("stainless 316")
assert all("316" in m.name.lower() or m.grade == "316L" for m in narrower)
print(f"Narrowed: {[m.name for m in narrower]}")


# %% [markdown]
# # 4. Vis properties inherited by grades (#88)
#
# Before 3.4 a grade without its own `[vis]` TOML section had `vis.source = None`.
# Now grades inherit the parent's vis — including textures, scalars, and
# the finishes map — while remaining independently mutable.

# %%
stainless = pymat["Stainless Steel"]
s304 = pymat["Stainless Steel 304"]

# Both have real vis identity
assert stainless.vis.source == "ambientcg"
assert s304.vis.source == "ambientcg"
assert s304.vis.material_id == stainless.vis.material_id
assert s304.vis.metallic == 1.0

# %%
# Finishes are copied in — switch appearance on a grade without touching parent
s304.vis.finish = "polished"
assert s304.vis.material_id != stainless.vis.material_id
print(f"s304 polished: {s304.vis.source}/{s304.vis.material_id}")
s304.vis.finish = "brushed" # restore so downstream cells see consistent state


# %% [markdown]
# # 5. Adapter output: Three.js / glTF / MaterialX
#
# Three export formats from every `Material`. Method form and
# module-level function produce identical output — pick whichever reads
# better at the call site.

# %%
threejs_dict = s304.vis.to_threejs()
print("Three.js fields:", sorted(threejs_dict.keys()))
assert "metalness" in threejs_dict
assert "roughness" in threejs_dict

# %%
gltf_node = s304.vis.to_gltf(name=s304.name)
assert "pbrMetallicRoughness" in gltf_node
assert gltf_node["pbrMetallicRoughness"]["metallicFactor"] == 1.0
print("glTF material:", gltf_node["name"], "→", list(gltf_node["pbrMetallicRoughness"].keys()))


# %% [markdown]
# # 6. build123d integration — shape.material and export_gltf
#
# Today's baseline: `apply_to()` + `export_gltf()` produces a glTF with
# the material's base color. Metallic / roughness / textures don't flow
# through build123d 0.10's exporter — that's the gap build123d#1270 is
# closing.

# %%
try:
from build123d import Box, export_gltf

BUILD123D_AVAILABLE = True
except ImportError:
BUILD123D_AVAILABLE = False
print("build123d not installed — skipping shape cells")


# %%
# The current baseColor-only path — proves materials carry through to glTF.
if BUILD123D_AVAILABLE:
import json
import tempfile
from pathlib import Path

housing = Box(50, 50, 10)
s304.apply_to(housing)
assert housing.material.name == "Stainless Steel 304"
assert housing.color is not None
assert housing.mass > 0

out = Path(tempfile.mkdtemp()) / "housing.glb"
export_gltf(housing, str(out))
doc = json.loads(out.read_text())
assert doc.get("materials"), "material should land in glTF"
print(f"glTF materials[0]: {doc['materials'][0]}")


# %%
# Direct assignment: `shape.material = m` (no apply_to). Today this sets
# the attribute but doesn't reach build123d's export_gltf (which reads
# shape.color only). That's exactly the gap #1270 closes.
if BUILD123D_AVAILABLE:
bracket = Box(30, 20, 5)
bracket.material = bolt_mat # no apply_to → no shape.color
# Materials class (PR #1270) would populate everything via a single hook.


# %% [markdown]
# # 7. MaterialX full package (DCC pipelines)
#
# For Houdini / Blender Cycles / USD pipelines, the richer authoring
# format. Skip if the mat-vis mirror can't reach the asset.

# %%
try:
xml_doc = s304.vis.mtlx.xml()
assert xml_doc and "<materialx" in xml_doc.lower()
print(f"MaterialX XML: {len(xml_doc)} chars")
except Exception as e: # pragma: no cover — upstream flake
print(f"mat-vis MTLX not reachable ({type(e).__name__}), skipping")


# %% [markdown]
# # 8. The mat-vis index is thin but real
#
# Direct visual-catalog search for "find me a metal texture" use cases.
# On a cold CI runner this returns few results while the bake pipeline
# catches up — see build123d#1270 comment for the full mirror status.

# %%
from pymat import vis # noqa: E402 — cell-style script, imports near use

baked_metals = vis.search(category="metal", limit=5)
print(f"Baked metals in mat-vis: {len(baked_metals)}")
for hit in baked_metals:
print(f" - {hit['source']}/{hit['id']} tier={hit.get('default_tier', '1k')}")


# %% [markdown]
# # 9. Cleanup / summary
#
# Everything above ran top-to-bottom as a smoke test. The matching
# pytest runner calls `runpy` to exercise every cell on every CI run —
# if the API shape drifts, these examples go red before build123d
# picks them up.

# %%
print("All cells completed.")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "py-materials"
version = "3.3.0"
version = "3.4.0"
description = "Hierarchical material library for CAD applications with build123d integration"
readme = "README.md"
license = "MIT"
Expand Down
67 changes: 66 additions & 1 deletion src/pymat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
from .search import search
from .units import ureg

__version__ = "3.3.0" # x-release-please-version
__version__ = "3.4.0" # x-release-please-version
__all__ = [
"Material",
"AllProperties",
Expand Down Expand Up @@ -182,6 +182,71 @@ def __getattr__(name: str) -> Material:
raise AttributeError(f"module 'pymat' has no attribute '{name}'")


def _lookup(name_or_key: str) -> Material:
"""Exact-lookup implementation for ``pymat["..."]``.

Resolves ``name_or_key`` against the registered material library via
``search(..., exact=True)``. Raises ``KeyError`` for empty queries,
misses, and ambiguous matches — the candidate list is attached to
the error message so the user can pick.

Backing the subscript form (``pymat["Stainless Steel 304"]``) is the
idiomatic Python-registry pattern (see ``os.environ``, ``sys.modules``,
``collections.ChainMap``) — raises on miss by convention, unlike
``dict.get`` which returns None.
"""
if not isinstance(name_or_key, str):
raise TypeError(f"pymat[...] takes a string key or name, got {type(name_or_key).__name__}")
if not name_or_key.strip():
raise KeyError("pymat[...] requires a non-empty material name or key; got empty/whitespace")

hits = search(name_or_key, exact=True, limit=50)
if not hits:
# Offer the closest fuzzy matches so the user can see what was
# close — far more useful than a bare KeyError.
fuzzy = search(name_or_key, limit=5)
if fuzzy:
suggestions = ", ".join(repr(m.name) for m in fuzzy)
raise KeyError(
f"No material matches {name_or_key!r} exactly. "
f"Close matches: {suggestions}. "
f"Use pymat.search({name_or_key!r}) for the full fuzzy list."
)
raise KeyError(f"No material matches {name_or_key!r}")
if len(hits) > 1:
names = ", ".join(repr(m.name) for m in hits[:8])
raise KeyError(
f"Ambiguous: {len(hits)} materials match {name_or_key!r} "
f"(key, name, or grade). Candidates: {names}"
f"{' …' if len(hits) > 8 else ''}. "
f"Use a more specific query or index by key."
)
return hits[0]


# Install module-level __getitem__ so ``pymat["Stainless Steel 304"]`` works.
# PEP 562 covers module-level __getattr__ but not __getitem__; the standard
# pattern is to swap the module's __class__ for a subtype of ModuleType that
# defines __getitem__. Python's import machinery is unaffected.
import sys as _sys # noqa: E402 — must run after _lookup / Material definitions
import types as _types # noqa: E402 — same reason


class _PymatModule(_types.ModuleType):
def __getitem__(self, key: str) -> Material: # type: ignore[override]
return _lookup(key)

def __contains__(self, key: str) -> bool: # type: ignore[override]
try:
_lookup(key)
except KeyError:
return False
return True


_sys.modules[__name__].__class__ = _PymatModule


def __dir__() -> list[str]:
"""
Help IDE discover available materials.
Expand Down
Loading
Loading