From 5d7724f7c968144cbfda486ddb498b6e5b6feb93 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 15:55:19 +0200 Subject: [PATCH 1/6] chore(lint): harden .typos.toml + gitignore examples/output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .typos.toml: add `metalness` and `metalnessMap` to extend-words. These are Three.js MeshPhysicalMaterial API keys; typos auto-rewrites them to `metallicity`/`metallicityMap` (not words) without this pin. Same failure class as the earlier Macor → Macro and Nd → And incidents from the bootstrap session. - .gitignore: exclude `examples/output/` — runtime output dir for the new examples/pbr_integration.py script. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ .typos.toml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 880c208..8639605 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,6 @@ justfile.local # Cursor local config .cursor/ + +# Example script runtime output +examples/output/ diff --git a/.typos.toml b/.typos.toml index e31e352..8c2a703 100644 --- a/.typos.toml +++ b/.typos.toml @@ -17,6 +17,10 @@ macor = "macor" Ba = "Ba" # Chemical element symbols that otherwise look like typos. Nd = "Nd" +# Three.js MeshPhysicalMaterial parameter name. typos flags this +# as a typo for "metallicity", which isn't a word. See ADR-0002. +metalness = "metalness" +metalnessMap = "metalnessMap" [files] extend-exclude = [ From 8a4b95a4b3b734802b452bc82dd5b5a61467f9a5 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 15:55:44 +0200 Subject: [PATCH 2/6] feat(pbr): optional threejs-materials backend via pymat.pbr Protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0002: add PBR integration as an optional extra so py-materials can carry full MaterialX rendering data while physics- only users stay lean. Design ------ - `pymat.pbr.PbrSource` — runtime_checkable typing Protocol with one method, `to_three_js_dict()`. Any conforming object can be assigned to `Material.pbr_source`. - Native lite `PBRProperties` dataclass gains `to_three_js_dict()` so it satisfies the Protocol. Outputs Three.js MeshPhysicalMaterial camelCase keys (color, metalness, roughness, transmission, opacity, transparent, emissive, ior, clearcoat, normalMap, roughnessMap, metalnessMap, aoMap) with defaults omitted. - `Material` gains `pbr_source: Optional[PbrSource] = None` field and `to_three_js_material_dict()` method. When `pbr_source` is set, it takes precedence over `properties.pbr` (the lite native backend) for rendering output. - `[pbr]` optional extra pins `threejs-materials>=1.0.0` (Bernhard's Apache-2.0 library, canonical PBR loader consumed by ocp_vscode). Also added to the `all` extra. - `from pymat.pbr import PbrProperties` is a conditional re-export of `threejs_materials.PbrProperties` when the extra is installed, so users can write one import and don't need to know about the underlying library. Usage ----- Without the extra (physics-only path, default install): from pymat import Material steel = Material( name="Steel", density=7.85, pbr={"base_color": (0.75, 0.75, 0.77, 1.0), "metallic": 1.0}, ) steel.to_three_js_material_dict() # → lite backend output With the extra (`pip install py-materials[pbr]`): from pymat import Material from pymat.pbr import PbrProperties steel = Material( name="Brushed Steel", density=7.85, formula="Fe", pbr_source=PbrProperties.from_gpuopen("Stainless Steel Brushed"), ) steel.to_three_js_material_dict() # → rich threejs-materials output Tests ----- - 7 new tests in tests/test_pbr.py covering: - Protocol conformance (isinstance check via runtime_checkable) - Native lite backend serialization (minimal + full field coverage) - Material dispatch to lite vs rich backend (via a stub conforming to the Protocol — no threejs-materials install required for the base test suite) - Non-conforming object rejection - Full suite 140 passed / 11 skipped (was 133 / 11 before). Documentation ------------- - docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md captures the design rationale, six options considered, and the upgrade trigger (when a second PBR backend emerges, or if threejs-materials's maintenance changes). - examples/pbr_integration.py — runnable end-to-end demo. Prints physics properties, emits Three.js JSON to stdout, writes JSON to examples/output/ for downstream viewer consumption. Graceful degrade when [pbr] not installed. Includes a --visual flag that opens the Material in ocp_vscode (manual path; automated headless ocp_vscode snapshot is tracked as a follow-up). Refs: #3 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...br-via-threejs-materials-optional-extra.md | 200 +++++++++++++ examples/pbr_integration.py | 173 +++++++++++ pyproject.toml | 5 + src/pymat/core.py | 27 +- src/pymat/pbr/__init__.py | 34 +++ src/pymat/pbr/_protocol.py | 34 +++ src/pymat/properties.py | 43 +++ tests/test_pbr.py | 104 +++++++ uv.lock | 274 +++++++++++++++--- 9 files changed, 845 insertions(+), 49 deletions(-) create mode 100644 docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md create mode 100644 examples/pbr_integration.py create mode 100644 src/pymat/pbr/__init__.py create mode 100644 src/pymat/pbr/_protocol.py create mode 100644 tests/test_pbr.py diff --git a/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md new file mode 100644 index 0000000..e062ab2 --- /dev/null +++ b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md @@ -0,0 +1,200 @@ +# 0002. PBR integration via threejs-materials as optional `[pbr]` extra + +- Status: Accepted +- Date: 2026-04-15 +- Deciders: @gerchowl +- Context issue: [#3](https://github.com/MorePET/mat/issues/3) + +## Context + +Issue #3 (from Roger Maitland / @gumyr, author of build123d) asks for +`py-materials` to become the material layer for build123d, with +support for loading PBR (physically-based rendering) materials from +the four major open MaterialX libraries — ambientcg, polyhaven, +gpuopen, physicallybased.info — so build123d shapes can carry +texture-driven materials and render in `ocp_vscode` with full PBR. + +Investigation turned up [`bernhard-42/threejs-materials`][tjm], a +pure-Python Apache-2.0 library (v1.0.0) that **already does all the +heavy lifting**: + +- Loaders for all four MaterialX sources +- MaterialX shader-graph baking into flat textures +- Texture cache + `resolution` tier selection +- Output as Three.js `MeshPhysicalMaterial`-shaped JSON + +`ocp_vscode` already consumes it directly — the example at +`bernhard-42/vscode-ocp-cad-viewer/examples/material-object.py` has + +```python +shader_ball.material = PbrProperties.from_gpuopen("Stainless Steel Brushed") +``` + +with a `FutureWarning` saying *"the required type of +`build123d`'s `shape.material` will change"*. That warning is +explicitly waiting for `py-materials` to become the canonical +carrier type. + +**Design question**: how should `py-materials` integrate with +`threejs-materials` and `build123d`? + +## Decision + +`py-materials` **depends on `threejs-materials` as an optional +`[pbr]` extra** and defines a narrow `PbrSource` typing `Protocol` +that both the native lite `PBRProperties` dataclass and +`threejs_materials.PbrProperties` conform to. `Material` gains an +optional `pbr_source` field typed as `Optional[PbrSource]` that +carries the rich backend when present, alongside the existing +`properties.pbr` (the lite native in-tree dataclass). + +Concretely: + +```python +# pymat/pbr/_protocol.py +@runtime_checkable +class PbrSource(Protocol): + def to_three_js_dict(self) -> dict: ... + +# pymat/pbr/__init__.py +try: + from threejs_materials import PbrProperties # when [pbr] extra installed +except ImportError: + pass +``` + +```python +# pymat/core.py +@dataclass +class _MaterialInternal: + ... + pbr_source: Optional["PbrSource"] = None + + def to_three_js_material_dict(self) -> dict: + """Pick the right backend and emit Three.js JSON.""" + if self.pbr_source is not None: + return self.pbr_source.to_three_js_dict() + return self.properties.pbr.to_three_js_dict() +``` + +Usage: + +```python +from pymat import Material +from pymat.pbr import PbrProperties # requires [pbr] extra + +steel = Material( + name="Brushed Steel", + density=7.85, + formula="Fe", + pbr_source=PbrProperties.from_gpuopen("Stainless Steel Brushed"), +) +shape.material = steel # future build123d.Shape.material integration + +# Both consumers read from the same object: +json_for_viewer = steel.to_three_js_material_dict() +density_for_mass = steel.density +molar_mass_for_radiation = steel.molar_mass # see ADR-0001 +``` + +## Consequences + +**Enables**: + +- **Physics users stay lean.** `pip install py-materials` does not + pull `pillow`, `pygltflib`, `requests`, or any of the + texture-library HTTP surface. Monte Carlo particle-transport + users (the README's primary use case) are unaffected by this + ADR. +- **PBR users get full MaterialX support** with a single extra: + `pip install py-materials[pbr]`. Downloads, caches, baking, and + Three.js output are all handled by `threejs-materials` without + `py-materials` maintaining the HTTP / texture / MaterialX + code. +- **The canonical type for `shape.material` is `pymat.Material`**, + carrying both physics (density, thermal, molar mass) AND PBR + (via `pbr_source`). `ocp_vscode` / build123d viewers call + `material.to_three_js_material_dict()` and get uniform output + regardless of backend. +- **Zero cross-repo code duplication.** `threejs-materials` stays + the single source of truth for PBR loading. `py-materials` stays + the single source of truth for materials science. `build123d` + stays the single source of truth for CAD shapes. +- **Independent release cadences.** Each library evolves on its + own schedule. Version-compat is a semver pin, managed by + dependabot. +- **Protocol-based typing** lets users plug in custom PBR backends + (for example, a future `pymat.pbr` loader for a proprietary + texture library) without touching py-materials. + +**Costs**: + +- **Two parallel PBR code paths** on py-materials' side. The lite + `PBRProperties` dataclass stays (for TOML-authored materials and + users without the extra) and grows a `to_three_js_dict()` + method that implements the Protocol. The rich path is the + optional extra. Some duplication of intent between the two + serializers is unavoidable; their outputs may drift on edge + cases unless consciously kept in sync. +- **`threejs-materials` v2.x release would be a coordinated + update** with a dependabot PR + CI matrix check. Not much effort + but requires attention. +- **First-time users of the `[pbr]` extra pay a cold-install + cost**: `pillow`, `pygltflib`, `requests` plus their transitive + deps. ~10-20 MB added to the environment. + +**Rules out**: + +- **Vendoring `threejs-materials` into `py-materials`** (the + "absorb" option). That would require py-materials to track + Bernhard's changes manually, duplicate ~3000 lines of code, + and compete with a library that's actively maintained by + someone else. +- **Making `threejs-materials` depend on `py-materials`** + (reverse direction). Semantically wrong — the physics layer + shouldn't be a transitive dep of a rendering loader. +- **A required `threejs-materials` dep on `py-materials`**. + Bloats the physics-only install. + +## Alternatives considered + +- **Option I — required dep**: `threejs-materials` in + `dependencies`. Rejected: bloats physics-only users who don't + care about PBR rendering. +- **Option III — reverse dep**: `threejs-materials` depends on + `py-materials`. Rejected: conceptually backwards, couples + Bernhard's library to our release cadence. +- **Option IV — Protocol-only, no dep either way**: users install + both libraries manually and the Protocol is the only + connection. Rejected as the first user experience: + `pip install py-materials[pbr]` is the obvious wire. +- **Option V — absorb `threejs-materials` into `py-materials`**: + vendor the code. Rejected for the reasons above. +- **Option VI — no PBR integration**: py-materials stays + physics-only. Rejected: violates the spirit of issue #3 and + the build123d integration story. + +See the session discussion on [#3][#3] for the full +option-matrix and first-principles analysis. + +## Upgrade trigger + +Revisit this ADR if any of these happen: + +1. **`threejs-materials` adds a hard dep that py-materials users + object to** (e.g., a GPU-accelerated texture baker). Might + force reverting to a vendored or proxied approach. +2. **A second PBR backend emerges** (e.g., an OpenUSD-native + loader) that users want alongside `threejs-materials`. The + Protocol already supports this — just update the docs and + `pymat.pbr.__init__` to pick up the second backend when + installed. +3. **Bernhard steps away from `threejs-materials`**. py-materials + may then need to either fork or rewrite. The Protocol boundary + means either option keeps the downstream API stable. +4. **The `[pbr]` install cost becomes painful for common users** + (e.g., Pillow stops being pure-Python). Might need to trim or + split the extra. + +[tjm]: https://github.com/bernhard-42/threejs-materials +[#3]: https://github.com/MorePET/mat/issues/3 diff --git a/examples/pbr_integration.py b/examples/pbr_integration.py new file mode 100644 index 0000000..01d4413 --- /dev/null +++ b/examples/pbr_integration.py @@ -0,0 +1,173 @@ +""" +End-to-end example: Material with physics + PBR, Three.js JSON output. + +Demonstrates the `pymat.pbr` Protocol-based integration from ADR-0002. +Works with both the lite in-tree backend (no extra deps) and the rich +`threejs-materials` backend (install `pip install py-materials[pbr]`). + +Run: + python examples/pbr_integration.py + +Outputs: + - Physics properties to stdout + - Three.js MeshPhysicalMaterial dict to stdout + - Writes the JSON to `examples/output/steel_material.json` for + downstream viewer consumption + +To verify visually in ocp_vscode (manual, requires VS Code + the +OCP CAD Viewer extension): + 1. `pip install py-materials[pbr] build123d ocp-vscode` + 2. Run this script with `--visual` to render a shader_ball in the + viewer (see the block at the bottom of this file) + 3. Take a screenshot from the viewer's camera panel for snapshot + verification (automated headless snapshotting of ocp_vscode is + not currently feasible — tracked as a follow-up) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from pymat import Material +from pymat.pbr import PbrSource + +OUTPUT_DIR = Path(__file__).parent / "output" +OUTPUT_DIR.mkdir(exist_ok=True) + + +def build_steel_with_lite_pbr() -> Material: + """ + Build a `Material` using only the native in-tree PBR backend. + + This path works with `pip install py-materials` — no extras + needed. Physics users get a usable material with basic PBR + scalar values; no texture maps. + """ + return Material( + name="Stainless Steel 304", + density=8.0, + formula="Fe", # dominant element, approximated for molar mass + mechanical={"youngs_modulus": 193, "yield_strength": 170}, + thermal={"melting_point": 1450, "thermal_conductivity": 15.1}, + pbr={ + "base_color": (0.75, 0.75, 0.77, 1.0), + "metallic": 1.0, + "roughness": 0.35, + }, + ) + + +def build_steel_with_rich_pbr() -> Material | None: + """ + Build a `Material` using the rich `threejs-materials` backend. + + Requires `pip install py-materials[pbr]`. Downloads the + "Stainless Steel Brushed" MaterialX material from + matlib.gpuopen.com on first run and caches it for subsequent + runs. Returns None if the extra is not installed. + """ + try: + from pymat.pbr import PbrProperties # type: ignore[attr-defined] + except ImportError: + return None + + return Material( + name="Brushed Stainless Steel", + density=8.0, + formula="Fe", + mechanical={"youngs_modulus": 193, "yield_strength": 170}, + thermal={"melting_point": 1450, "thermal_conductivity": 15.1}, + pbr_source=PbrProperties.from_gpuopen("Stainless Steel Brushed"), + ) + + +def report(material: Material, label: str) -> dict: + """Print a summary of a Material and return its Three.js dict.""" + print(f"\n=== {label} ===") + print(f" name: {material.name}") + print(f" density: {material.density} g/cm³") + print(f" formula: {material.formula}") + print(f" molar mass: {material.molar_mass} g/mol") + print(f" pbr_source set: {material.pbr_source is not None}") + + three_js = material.to_three_js_material_dict() + print(" Three.js dict:") + print(json.dumps(three_js, indent=4, sort_keys=True)) + + # Sanity: whichever backend is active, it conforms to the Protocol. + source: PbrSource = ( + material.pbr_source if material.pbr_source is not None else material.properties.pbr + ) + assert isinstance(source, PbrSource), ( + f"Active PBR backend {type(source).__name__} does not conform to PbrSource" + ) + return three_js + + +def main() -> int: + lite_steel = build_steel_with_lite_pbr() + lite_dict = report(lite_steel, "Lite backend (zero extra deps)") + (OUTPUT_DIR / "steel_lite.json").write_text( + json.dumps(lite_dict, indent=2, sort_keys=True) + "\n" + ) + + rich_steel = build_steel_with_rich_pbr() + if rich_steel is not None: + rich_dict = report(rich_steel, "Rich backend (threejs-materials)") + (OUTPUT_DIR / "steel_rich.json").write_text( + json.dumps(rich_dict, indent=2, sort_keys=True) + "\n" + ) + else: + print( + "\n=== Rich backend skipped ===\n" + " Install `pip install py-materials[pbr]` to fetch MaterialX\n" + " materials from ambientcg / polyhaven / gpuopen / physicallybased.info." + ) + + print(f"\nJSON written to {OUTPUT_DIR.resolve()}") + return 0 + + +# --------------------------------------------------------------------------- +# Optional: ocp_vscode visual rendering block. +# --------------------------------------------------------------------------- +# This block is skipped by default. To run it interactively: +# +# pip install py-materials[pbr] build123d ocp-vscode +# python examples/pbr_integration.py --visual +# +# It requires VS Code with the OCP CAD Viewer extension running, and +# opens a `shader_ball` with the material applied. Manual screenshot +# capture is currently the only way to snapshot — automated headless +# snapshotting of ocp_vscode is tracked as a separate follow-up. + + +def visual_demo() -> int: # pragma: no cover + """Render a shader_ball with the rich steel material in ocp_vscode.""" + try: + from build123d import Box + from ocp_vscode import show # type: ignore[import-not-found] + except ImportError as e: + print(f"Visual demo requires [pbr] + build123d + ocp_vscode: {e}") + return 1 + + # A build123d shader ball would be ideal but the helper lives in + # `ocp_vscode.utils.create_shader_ball` and requires its own + # tesselation; we use a simple Box to keep the example minimal. + shape = Box(50, 50, 50) + steel = build_steel_with_rich_pbr() + assert steel is not None + # Until build123d ships `Shape.material` as a first-class attribute + # (tracked in issue #3 + pending build123d PR), we set it as an + # ad-hoc attribute — matches the current ocp_vscode convention. + shape.material = steel # type: ignore[attr-defined] + show(shape) + return 0 + + +if __name__ == "__main__": + if "--visual" in sys.argv: + sys.exit(visual_demo()) + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index c65abc5..fbc4546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ matproj = ["pymatgen>=2024.0.0"] # py-materials[build123d]` on Python 3.13+ is a no-op rather than a hard # wheel-resolution error — installers silently drop the dep. See #11. build123d = ["build123d>=0.7.0; python_version<'3.13'"] +# Full MaterialX PBR backend via Bernhard's threejs-materials library. +# Pure-Python, Apache-2.0, brings pillow + pygltflib + requests with it. +# Physics-only users never pull this. See ADR-0002. +pbr = ["threejs-materials>=1.0.0"] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -49,6 +53,7 @@ all = [ "periodictable>=1.6.0", "pymatgen>=2024.0.0", "build123d>=0.7.0; python_version<'3.13'", + "threejs-materials>=1.0.0", ] [project.urls] diff --git a/src/pymat/core.py b/src/pymat/core.py index 24aaa67..e9f6c18 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, TypeVar if TYPE_CHECKING: - pass + from .pbr import PbrSource from .properties import AllProperties @@ -114,6 +114,13 @@ class _MaterialInternal: # Properties (all domains) properties: AllProperties = field(default_factory=AllProperties) + # Optional rich PBR backend. When set, takes precedence over + # `properties.pbr` (the lite native dataclass) for rendering. See + # ADR-0002 — this is the integration point for + # `threejs_materials.PbrProperties` and any other external PBR + # loader that conforms to the `pymat.pbr.PbrSource` Protocol. + pbr_source: Optional["PbrSource"] = field(default=None, repr=False) + # Hierarchy (not shown in repr) parent: Optional[_MaterialInternal] = field(default=None, repr=False) _children: Dict[str, _MaterialInternal] = field(default_factory=dict, repr=False) @@ -450,6 +457,19 @@ def mass_from_volume_mm3(self, volume_mm3: float) -> float: """Calculate mass in grams from volume in mm³.""" return volume_mm3 * self.density_g_mm3 + def to_three_js_material_dict(self) -> dict: + """ + Return a Three.js ``MeshPhysicalMaterial`` dict for this material. + + Uses `pbr_source` if set (rich backend such as + `threejs_materials.PbrProperties` with full MaterialX + support), otherwise falls back to `properties.pbr` (the + lite native in-tree backend). See ADR-0002. + """ + if self.pbr_source is not None: + return self.pbr_source.to_three_js_dict() + return self.properties.pbr.to_three_js_dict() + def __repr__(self) -> str: """String representation showing path and density.""" density_str = f"ρ={self.density} g/cm³" if self.density else "ρ=?" @@ -502,6 +522,9 @@ class Material(_MaterialInternal): - electrical: Dict of electrical properties (resistivity, dielectric_constant, etc.) - optical: Dict of optical properties (refractive_index, transparency, etc.) - PHYSICS - pbr: Dict of PBR visualization properties (base_color, metallic, roughness) - RENDERING + - pbr_source: Optional rich PBR backend conforming to `pymat.pbr.PbrSource` + (typically `threejs_materials.PbrProperties` via the `[pbr]` extra). + Takes precedence over `pbr` for `to_three_js_material_dict()`. - manufacturing: Dict of manufacturing properties (machinability, weldability, etc.) - compliance: Dict of compliance properties (rohs_compliant, food_safe, etc.) - sourcing: Dict of sourcing properties (cost_per_kg, availability, etc.) @@ -529,6 +552,7 @@ def __init__( electrical: Optional[Dict[str, Any]] = None, optical: Optional[Dict[str, Any]] = None, pbr: Optional[Dict[str, Any]] = None, + pbr_source: Optional["PbrSource"] = None, manufacturing: Optional[Dict[str, Any]] = None, compliance: Optional[Dict[str, Any]] = None, sourcing: Optional[Dict[str, Any]] = None, @@ -551,6 +575,7 @@ def __init__( electrical=electrical, optical=optical, pbr=pbr, + pbr_source=pbr_source, manufacturing=manufacturing, compliance=compliance, sourcing=sourcing, diff --git a/src/pymat/pbr/__init__.py b/src/pymat/pbr/__init__.py new file mode 100644 index 0000000..6f5dbb9 --- /dev/null +++ b/src/pymat/pbr/__init__.py @@ -0,0 +1,34 @@ +""" +PBR (physically-based rendering) integration for `pymat.Material`. + +This package defines the `PbrSource` Protocol that `Material.pbr_source` +accepts. Any object conforming to the protocol can be assigned — the +native `PBRProperties` dataclass (lite, in-tree, no extra deps) and the +optional `threejs_materials.PbrProperties` (rich, full MaterialX +support) both satisfy it. + +Install the `[pbr]` extra to get the rich backend: + + pip install py-materials[pbr] + +Then `from pymat.pbr import PbrProperties` re-exports +`threejs_materials.PbrProperties`, the canonical PBR loader for +build123d / ocp_vscode. See ADR-0002 for the design rationale. +""" + +from __future__ import annotations + +from pymat.pbr._protocol import PbrSource + +__all__ = ["PbrSource"] + +# Optional re-export: threejs_materials.PbrProperties when the [pbr] +# extra is installed. Keeps `from pymat.pbr import PbrProperties` +# working as a thin alias, so downstream code doesn't need to know +# which underlying library provides the loader. +try: + from threejs_materials import PbrProperties # noqa: F401 + + __all__.append("PbrProperties") +except ImportError: # pragma: no cover + pass diff --git a/src/pymat/pbr/_protocol.py b/src/pymat/pbr/_protocol.py new file mode 100644 index 0000000..7af561a --- /dev/null +++ b/src/pymat/pbr/_protocol.py @@ -0,0 +1,34 @@ +"""The `PbrSource` typing Protocol.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PbrSource(Protocol): + """A renderable PBR material source. + + Any object implementing `to_three_js_dict()` is a valid `PbrSource`. + The native `pymat.properties.PBRProperties` and the optional + `threejs_materials.PbrProperties` both conform. + + Consumers (ocp_vscode, build123d viewers, custom renderers) should + call `to_three_js_dict()` to get a Three.js `MeshPhysicalMaterial`- + shaped dict, agnostic of which backend is providing the data. This + keeps the rendering pipeline decoupled from how the material was + sourced (TOML authored / runtime downloaded / MaterialX baked). + + See ADR-0002 for the design rationale. + """ + + def to_three_js_dict(self) -> dict: + """Return a Three.js `MeshPhysicalMaterial` dict. + + Conforming implementations should use camelCase keys matching + Three.js's expected parameter names (`color`, `metalness`, + `roughness`, `transmission`, `opacity`, `transparent`, `emissive`, + `ior`, `clearcoat`, `normalMap`, etc.) and omit fields whose + value is the Three.js default. + """ + ... diff --git a/src/pymat/properties.py b/src/pymat/properties.py index 202b255..246f6fb 100644 --- a/src/pymat/properties.py +++ b/src/pymat/properties.py @@ -329,6 +329,49 @@ class PBRProperties: metallic_map: Optional[str] = None ambient_occlusion_map: Optional[str] = None + def to_three_js_dict(self) -> dict: + """ + Serialize to a Three.js ``MeshPhysicalMaterial`` dict. + + Makes this class conform to the `pymat.pbr.PbrSource` Protocol + so it can be assigned to `Material.pbr_source` as the + lightweight, in-tree, zero-dependency backend. For full + MaterialX / texture-library support, use + `threejs_materials.PbrProperties` instead (install the + `[pbr]` extra). + + Keys are Three.js camelCase. Defaults are omitted so the + result stays compact. + """ + out: dict = {} + r, g, b, a = self.base_color + out["color"] = [r, g, b] + if self.metallic != 0.0: + out["metalness"] = self.metallic + if self.roughness != 0.5: + out["roughness"] = self.roughness + if self.emissive != (0, 0, 0): + out["emissive"] = list(self.emissive) + if self.ior != 1.5: + out["ior"] = self.ior + if self.transmission > 0.0: + out["transmission"] = self.transmission + if self.clearcoat > 0.0: + out["clearcoat"] = self.clearcoat + if a < 1.0: + out["opacity"] = a + out["transparent"] = True + # Texture maps — keys use Three.js naming (normalMap, etc.). + if self.normal_map is not None: + out["normalMap"] = self.normal_map + if self.roughness_map is not None: + out["roughnessMap"] = self.roughness_map + if self.metallic_map is not None: + out["metalnessMap"] = self.metallic_map + if self.ambient_occlusion_map is not None: + out["aoMap"] = self.ambient_occlusion_map + return out + @dataclass class ManufacturingProperties: diff --git a/tests/test_pbr.py b/tests/test_pbr.py new file mode 100644 index 0000000..54f2003 --- /dev/null +++ b/tests/test_pbr.py @@ -0,0 +1,104 @@ +""" +Tests for `pymat.pbr` — PbrSource Protocol + native backend serializer. + +Covers the lite in-tree path (PBRProperties.to_three_js_dict) and the +Material.to_three_js_material_dict dispatch. The rich threejs-materials +backend is exercised via a duck-typed stub to avoid pulling the extra +into the base test dependency set. See ADR-0002. +""" + +from __future__ import annotations + +import pytest + +from pymat import Material +from pymat.pbr import PbrSource +from pymat.properties import PBRProperties + + +class TestPbrSourceProtocol: + """Native `PBRProperties` should satisfy the `PbrSource` Protocol.""" + + def test_native_pbr_properties_is_pbr_source(self): + pbr = PBRProperties() + assert isinstance(pbr, PbrSource) + + def test_native_minimal_serialization(self): + """Default PBRProperties emits only `color` (all other fields + are at Three.js defaults and get omitted).""" + pbr = PBRProperties() + out = pbr.to_three_js_dict() + assert out == {"color": [0.8, 0.8, 0.8]} + + def test_native_full_serialization(self): + pbr = PBRProperties( + base_color=(0.7, 0.2, 0.2, 0.8), + metallic=1.0, + roughness=0.25, + transmission=0.3, + clearcoat=0.5, + normal_map="/path/to/normal.png", + ) + out = pbr.to_three_js_dict() + assert out["color"] == [0.7, 0.2, 0.2] + assert out["metalness"] == 1.0 + assert out["roughness"] == 0.25 + assert out["transmission"] == 0.3 + assert out["clearcoat"] == 0.5 + assert out["opacity"] == pytest.approx(0.8) + assert out["transparent"] is True + assert out["normalMap"] == "/path/to/normal.png" + + +class TestMaterialToThreeJs: + """`Material.to_three_js_material_dict()` picks the right backend.""" + + def test_falls_back_to_native_pbr(self): + steel = Material( + name="Steel", + density=7.85, + pbr={"base_color": (0.6, 0.6, 0.65, 1.0), "metallic": 1.0}, + ) + out = steel.to_three_js_material_dict() + assert out["color"] == [0.6, 0.6, 0.65] + assert out["metalness"] == 1.0 + + def test_pbr_source_takes_precedence(self): + """When `pbr_source` is set, the native `properties.pbr` is ignored.""" + + class FakeRichBackend: + """Stub conforming to PbrSource Protocol.""" + + def to_three_js_dict(self) -> dict: + return { + "color": [0.91, 0.91, 0.88], + "metalness": 1.0, + "roughness": 0.05, + "normalMap": "textures/brushed_steel_normal.png", + } + + steel = Material( + name="Brushed Steel", + density=7.85, + pbr={"base_color": (0.3, 0.3, 0.3, 1.0)}, # would lose if used + pbr_source=FakeRichBackend(), + ) + out = steel.to_three_js_material_dict() + # Rich backend's output, not the lite pbr dict. + assert out["color"] == [0.91, 0.91, 0.88] + assert out["normalMap"] == "textures/brushed_steel_normal.png" + + def test_pbr_source_stub_is_pbr_source(self): + """Any class with `to_three_js_dict()` satisfies the Protocol.""" + + class Stub: + def to_three_js_dict(self): + return {} + + assert isinstance(Stub(), PbrSource) + + def test_non_conforming_object_is_not_pbr_source(self): + class NotPbr: + pass + + assert not isinstance(NotPbr(), PbrSource) diff --git a/uv.lock b/uv.lock index c247bcd..c190b4f 100644 --- a/uv.lock +++ b/uv.lock @@ -44,24 +44,24 @@ name = "build123d" version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anytree" }, - { name = "cadquery-ocp" }, - { name = "ezdxf" }, + { name = "anytree", marker = "python_full_version < '3.14'" }, + { name = "cadquery-ocp", marker = "python_full_version < '3.14'" }, + { name = "ezdxf", marker = "python_full_version < '3.14'" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "lib3mf" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "lib3mf", marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "ocp-gordon" }, - { name = "ocpsvg" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "ocp-gordon", marker = "python_full_version < '3.14'" }, + { name = "ocpsvg", marker = "python_full_version < '3.14'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "svgpathtools" }, - { name = "sympy" }, - { name = "trianglesolver" }, - { name = "typing-extensions" }, - { name = "webcolors" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "svgpathtools", marker = "python_full_version < '3.14'" }, + { name = "sympy", marker = "python_full_version < '3.14'" }, + { name = "trianglesolver", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "webcolors", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/00/b56ebe1d2a31611dbfcc315d22ebb5403eddc60027d5f82acd493ebb5ac1/build123d-0.10.0.tar.gz", hash = "sha256:73ded38ddca8ebb95e7dd078ac3d7aacc8ca42fce8f1d176f1040e35fba4f608", size = 20011921, upload-time = "2025-11-05T22:04:37.054Z" } wheels = [ @@ -73,7 +73,7 @@ name = "cadquery-ocp" version = "7.8.1.1.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "vtk" }, + { name = "vtk", marker = "python_full_version < '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/61/a6/760c6383f8e0cac562583feab0d4733876522ee265f2cfa3a26bdffef330/cadquery_ocp-7.8.1.1.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4413961e98a90686a56c2ac58b126773c7da4eb82b967ddcc1f394fe6a7b71ad", size = 68085209, upload-time = "2025-01-29T14:33:03.08Z" }, @@ -497,6 +497,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -506,6 +519,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -532,11 +557,11 @@ name = "ezdxf" version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fonttools" }, + { name = "fonttools", marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyparsing" }, - { name = "typing-extensions" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "pyparsing", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/ff/e2fea17633a4c04abdf260d53e0d67463b01e11d957b8faaf3b195666e10/ezdxf-1.4.3.tar.gz", hash = "sha256:403adf7ce305877f6c9f3c007fe2e5c5df504dfb797032122abedd7170176764", size = 1816226, upload-time = "2025-10-19T03:48:12.137Z" } wheels = [ @@ -735,16 +760,16 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.12'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, - { name = "jedi", marker = "python_full_version >= '3.12'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, - { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "stack-data", marker = "python_full_version >= '3.12'" }, - { name = "traitlets", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "jedi", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "pygments", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "stack-data", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "traitlets", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } wheels = [ @@ -756,7 +781,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -768,7 +793,7 @@ name = "jedi" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "parso" }, + { name = "parso", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ @@ -918,6 +943,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/b3/7a844247dfa1f0b24de6fa0425afb808dd1feeef2d05d6335170433c1071/lib3mf-2.5.0-py3-none-win_amd64.whl", hash = "sha256:e378470855d634708c6ae7f2f2c6fcd89e4996350de86020849e7b2d51ffe6ac", size = 857560, upload-time = "2026-02-23T21:17:39.431Z" }, ] +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + [[package]] name = "matplotlib" version = "3.10.8" @@ -998,7 +1035,7 @@ name = "matplotlib-inline" version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "traitlets" }, + { name = "traitlets", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } wheels = [ @@ -1052,6 +1089,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "narwhals" version = "2.19.0" @@ -1249,11 +1295,11 @@ name = "ocp-gordon" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cadquery-ocp-proxy" }, + { name = "cadquery-ocp-proxy", marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/ed/3d8955df5d412bd6e711e7c0673d6420efe6ce3a106f0dbc50fc5cc82b3e/ocp_gordon-0.2.0.tar.gz", hash = "sha256:3ce1f1fb589e891534d0c1fd7553a518733336308837c4b5e5164d77df1cf5c9", size = 118840, upload-time = "2026-01-09T00:33:56.107Z" } wheels = [ @@ -1265,8 +1311,8 @@ name = "ocpsvg" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cadquery-ocp" }, - { name = "svgelements" }, + { name = "cadquery-ocp", marker = "python_full_version < '3.14'" }, + { name = "svgelements", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/e2/3ba2ad53395f91c72070cb3b8b033c5d9f9ba2ae6f61e869ef2a5f7f8ade/ocpsvg-0.5.0.tar.gz", hash = "sha256:5cd8dbec8bf590d373a82aaebeab241838185aab04ee2859f33b9d7956bbfba6", size = 54195, upload-time = "2025-02-21T15:54:12.333Z" } wheels = [ @@ -1532,7 +1578,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "python_full_version >= '3.11' or sys_platform != 'win32'" }, + { name = "ptyprocess", marker = "python_full_version < '3.14' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1715,7 +1761,7 @@ name = "prompt-toolkit" version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth" }, + { name = "wcwidth", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ @@ -1742,7 +1788,7 @@ wheels = [ [[package]] name = "py-materials" -version = "2.0.5" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "pint", version = "0.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1756,6 +1802,7 @@ all = [ { name = "periodictable" }, { name = "pymatgen", version = "2025.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pymatgen", version = "2026.3.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threejs-materials" }, ] build123d = [ { name = "build123d", marker = "python_full_version < '3.13'" }, @@ -1769,6 +1816,9 @@ matproj = [ { name = "pymatgen", version = "2025.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pymatgen", version = "2026.3.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +pbr = [ + { name = "threejs-materials" }, +] periodictable = [ { name = "periodictable" }, ] @@ -1790,13 +1840,28 @@ requires-dist = [ { name = "pymatgen", marker = "extra == 'matproj'", specifier = ">=2024.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "threejs-materials", marker = "extra == 'all'", specifier = ">=1.0.0" }, + { name = "threejs-materials", marker = "extra == 'pbr'", specifier = ">=1.0.0" }, { name = "tomli", marker = "python_full_version == '3.10.*'", specifier = ">=1.0.0" }, ] -provides-extras = ["periodictable", "matproj", "build123d", "dev", "all"] +provides-extras = ["periodictable", "matproj", "build123d", "pbr", "dev", "all"] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.15.10" }] +[[package]] +name = "pygltflib" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dataclasses-json" }, + { name = "deprecated" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/e8/f8232abdf9c085333689b0a428dcd1d0f83edd1ecafa6ed878a633d8c9d5/pygltflib-1.16.5.tar.gz", hash = "sha256:1f15740d5a7aaf71a5083e285af6b361184958e255659132f4ba8fe4f3d21ea9", size = 43272, upload-time = "2025-07-24T06:35:38.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/d6/7eb8a0e4eb30add2b76c957a41107a5f2ba26472d656e2733728bec0476b/pygltflib-1.16.5-py3-none-any.whl", hash = "sha256:41d3349c59dcf1586faeaee29c967be07ac2bf7cecdb8ae2b527da1f25afdaac", size = 27557, upload-time = "2025-07-24T06:35:37.328Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2234,9 +2299,9 @@ name = "stack-data" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, + { name = "asttokens", marker = "python_full_version < '3.14'" }, + { name = "executing", marker = "python_full_version < '3.14'" }, + { name = "pure-eval", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ @@ -2258,10 +2323,10 @@ version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "svgwrite" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "svgwrite", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/a0/06163182bdb00300a23e51b433a6ca5c881761e7dfbef432b4da44e3a84a/svgpathtools-1.7.2.tar.gz", hash = "sha256:5974daba24825e22f284ea10aa980d7d6f77a1ca55d914d80283e3ea8a7ac450", size = 2136092, upload-time = "2025-11-30T19:15:03.446Z" } wheels = [ @@ -2298,6 +2363,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "threejs-materials" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pygltflib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/7b/0f9e298de06134e36ffe1e4b365cda5605cc3a11c1b40936b941f357130f/threejs_materials-1.0.4.tar.gz", hash = "sha256:315b9dbb7d5b87daa3735326f758faacf0b906ca6c49b737e91dff8d1768dbff", size = 92571, upload-time = "2026-04-09T12:05:00.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/3a/59d0ad302eb4a5ca891b40c8ce1916690737b714d52f100ca696f23150d3/threejs_materials-1.0.4-py3-none-any.whl", hash = "sha256:d38e27dca790996e9599123e554b8d8186e616d458bf308b5d2f64c47451642e", size = 57526, upload-time = "2026-04-09T12:04:58.803Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -2391,6 +2470,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + [[package]] name = "tzdata" version = "2025.3" @@ -2423,7 +2515,7 @@ name = "vtk" version = "9.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib" }, + { name = "matplotlib", marker = "python_full_version < '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/95/1f/91780093a0cf2afc234063bb9697d1285ccba138c1531700bc2986e65adc/vtk-9.3.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:c41ed344b9cc90ee9dcfc5967815de272985647d0c8e0a57f0e8b4229bc1b0b9", size = 76687544, upload-time = "2024-06-29T03:14:20.271Z" }, @@ -2457,3 +2549,89 @@ sdist = { url = "https://files.pythonhosted.org/packages/fe/f8/53150a5bda7e04284 wheels = [ { url = "https://files.pythonhosted.org/packages/f0/33/12020ba99beaff91682b28dc0bbf0345bbc3244a4afbae7644e4fa348f23/webcolors-24.8.0-py3-none-any.whl", hash = "sha256:fc4c3b59358ada164552084a8ebee637c221e4059267d0f8325b3b560f6c7f0a", size = 15027, upload-time = "2024-08-10T08:52:28.707Z" }, ] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] From f78a4456b48468173bd28d76b2b22b8da6b8454a Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 16:06:45 +0200 Subject: [PATCH 3/6] =?UTF-8?q?docs(example):=20slim=20pbr=20example=20?= =?UTF-8?q?=E2=80=94=20visual=20demo=20moves=20to=20build123d=20fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `visual_demo()` block using build123d + ocp_vscode belongs in the build123d fork's examples dir (which already ships `[ocp_vscode]` as an optional extra), not in py-materials itself. py-materials' example stays minimal: demonstrate the py-mat API with lite + rich backends and emit Three.js JSON, no build123d dependency. The full composition example lives at `gerchowl/build123d@feature/pymat-material-integration:examples/pbr_material_pymat.py`, which imports both libraries and optionally calls `ocp_vscode.show()`. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/pbr_integration.py | 62 +++++++------------------------------ 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/examples/pbr_integration.py b/examples/pbr_integration.py index 01d4413..107ae99 100644 --- a/examples/pbr_integration.py +++ b/examples/pbr_integration.py @@ -11,23 +11,20 @@ Outputs: - Physics properties to stdout - Three.js MeshPhysicalMaterial dict to stdout - - Writes the JSON to `examples/output/steel_material.json` for - downstream viewer consumption - -To verify visually in ocp_vscode (manual, requires VS Code + the -OCP CAD Viewer extension): - 1. `pip install py-materials[pbr] build123d ocp-vscode` - 2. Run this script with `--visual` to render a shader_ball in the - viewer (see the block at the bottom of this file) - 3. Take a screenshot from the viewer's camera panel for snapshot - verification (automated headless snapshotting of ocp_vscode is - not currently feasible — tracked as a follow-up) + - Writes the JSON to `examples/output/` for downstream viewer + consumption + +This example deliberately avoids pulling in `build123d` or +`ocp_vscode` — it's the minimal py-materials-only demo. For the full +integration with build123d's `Shape.material` and live rendering in +`ocp_vscode`, see the matching example on the build123d fork: +`gerchowl/build123d@feature/pymat-material-integration:examples/pbr_material_pymat.py`, +which composes all three libraries. """ from __future__ import annotations import json -import sys from pathlib import Path from pymat import Material @@ -130,44 +127,7 @@ def main() -> int: return 0 -# --------------------------------------------------------------------------- -# Optional: ocp_vscode visual rendering block. -# --------------------------------------------------------------------------- -# This block is skipped by default. To run it interactively: -# -# pip install py-materials[pbr] build123d ocp-vscode -# python examples/pbr_integration.py --visual -# -# It requires VS Code with the OCP CAD Viewer extension running, and -# opens a `shader_ball` with the material applied. Manual screenshot -# capture is currently the only way to snapshot — automated headless -# snapshotting of ocp_vscode is tracked as a separate follow-up. - - -def visual_demo() -> int: # pragma: no cover - """Render a shader_ball with the rich steel material in ocp_vscode.""" - try: - from build123d import Box - from ocp_vscode import show # type: ignore[import-not-found] - except ImportError as e: - print(f"Visual demo requires [pbr] + build123d + ocp_vscode: {e}") - return 1 - - # A build123d shader ball would be ideal but the helper lives in - # `ocp_vscode.utils.create_shader_ball` and requires its own - # tesselation; we use a simple Box to keep the example minimal. - shape = Box(50, 50, 50) - steel = build_steel_with_rich_pbr() - assert steel is not None - # Until build123d ships `Shape.material` as a first-class attribute - # (tracked in issue #3 + pending build123d PR), we set it as an - # ad-hoc attribute — matches the current ocp_vscode convention. - shape.material = steel # type: ignore[attr-defined] - show(shape) - return 0 - - if __name__ == "__main__": - if "--visual" in sys.argv: - sys.exit(visual_demo()) + import sys + sys.exit(main()) From 3017a0150523fa380d779c35e97582829f7db8e9 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 16:33:42 +0200 Subject: [PATCH 4/6] feat(pbr): backfill lite properties.pbr from pbr_source + materialx extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graceful enhancement for existing downstream renderers that already read `material.properties.pbr.` directly, e.g. `ocp_vscode`'s `_extract_materials_from_node()` in `show.py`. ## The problem ADR-0002 added `Material.pbr_source` as the rich backend field. `Material.to_three_js_material_dict()` dispatches to the rich source when set. But existing ocp_vscode (and any other renderer that reads the lite `properties.pbr` dataclass directly) can't see the rich-backend data without a code change on their side. ## The fix When `pbr_source` is set, `__post_init__` now calls `_backfill_pbr_from_source()` which projects the rich backend's `to_three_js_dict()` output onto the lite dataclass: color, metalness, roughness, ior, emissive, transmission, clearcoat, plus normal/roughness/metalness/ao maps. Result: `material.pbr_source = PbrProperties.from_gpuopen(...)` renders with MaterialX textures through existing ocp_vscode today, **no adapter change on Bernhard's side required**. The rich source still takes precedence in `to_three_js_material_dict()` for callers that can handle extra fields (sheen, anisotropy, iridescence, etc.) not present in the lite dataclass. One-way copy at `__post_init__` only — not a live sync. Re-assigning `pbr_source` re-runs the backfill. Fields without a lite counterpart are dropped in the projection; the lossy subset is documented in the updated ADR-0002. ## Also - `[pbr]` extra now pulls `threejs-materials[materialx]>=1.0.0` instead of `threejs-materials>=1.0.0`. The `[materialx]` sub-extra brings the MaterialX SDK, without which `PbrProperties.from_gpuopen` errors on first load ("materialx is not installed"). - 5 new tests in `TestPbrBackfill` covering: scalar backfill, texture map backfill, adapter compatibility (simulates Bernhard's adapter reading `properties.pbr.`), no-op when pbr_source unset, rich source still wins in dispatch. - ADR-0002 updated with a dedicated "Backfill pattern" section. Full suite: 145 passed / 11 skipped (was 140 / 11). Refs: #3 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...br-via-threejs-materials-optional-extra.md | 34 ++++++ pyproject.toml | 5 +- src/pymat/core.py | 59 +++++++++ tests/test_pbr.py | 112 ++++++++++++++++++ uv.lock | 74 +++++++++++- 5 files changed, 280 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md index e062ab2..85a1873 100644 --- a/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md +++ b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md @@ -97,6 +97,40 @@ density_for_mass = steel.density molar_mass_for_radiation = steel.molar_mass # see ADR-0001 ``` +## Backfill pattern (graceful enhancement for existing consumers) + +Setting `Material.pbr_source` also **projects the rich backend's +serialized fields onto the lite `properties.pbr` dataclass** via an +internal `_backfill_pbr_from_source()` pass in `__post_init__`. This +copies the overlapping fields (color, metalness, roughness, ior, +emissive, transmission, clearcoat, normal/roughness/metalness/ao +maps) from `pbr_source.to_three_js_dict()` into the lite dataclass +one-way at construction time. + +Why: existing downstream renderers that read +`material.properties.pbr.` directly — for example, +`ocp_vscode`'s `_extract_materials_from_node()` in `show.py`, which +reads `base_color`, `metallic`, `roughness`, `normal_map`, etc. — +pick up the rich-backend data **without any code change on their +side**. A user can assign +`material.pbr_source = PbrProperties.from_gpuopen("...")` and +`ocp_vscode.show()` will render with the MaterialX textures today. + +Fields on the rich source that don't have a corresponding lite +field (sheen, anisotropy, iridescence, dispersion, clearcoat +normal/roughness maps, specular, thickness, displacement, etc.) +are dropped in the projection — the lite dataclass is a lossy +subset. Consumers that can handle the full fidelity should read +`material.pbr_source` directly or call +`material.to_three_js_material_dict()`, which delegates to the +rich source first and so preserves every field. + +The backfill is a one-way copy at `__post_init__` — it does not +keep the lite dataclass in sync if the rich source is mutated +later. That's intentional: mutating a loaded PBR material after +assignment is unusual, and re-assigning `pbr_source` will re-run +the backfill. + ## Consequences **Enables**: diff --git a/pyproject.toml b/pyproject.toml index fbc4546..e789b92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,10 @@ matproj = ["pymatgen>=2024.0.0"] # wheel-resolution error — installers silently drop the dep. See #11. build123d = ["build123d>=0.7.0; python_version<'3.13'"] # Full MaterialX PBR backend via Bernhard's threejs-materials library. -# Pure-Python, Apache-2.0, brings pillow + pygltflib + requests with it. +# Pure-Python, Apache-2.0, brings pillow + pygltflib + requests + the +# MaterialX SDK (via threejs-materials' own [materialx] extra) with it. # Physics-only users never pull this. See ADR-0002. -pbr = ["threejs-materials>=1.0.0"] +pbr = ["threejs-materials[materialx]>=1.0.0"] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", diff --git a/src/pymat/core.py b/src/pymat/core.py index e9f6c18..486ef20 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -199,6 +199,65 @@ def __post_init__(self): ): self.properties.pbr.transmission = self.properties.optical.transparency / 100.0 + # When a rich pbr_source is provided, project its fields down + # onto the lite `properties.pbr` dataclass. This lets existing + # ocp_vscode / downstream renderers that only read + # `material.properties.pbr.` pick up the rich data + # without any changes on their side (graceful enhancement). + # `to_three_js_material_dict()` still prefers the full rich + # source for callers that can handle it. + if self.pbr_source is not None: + self._backfill_pbr_from_source() + + def _backfill_pbr_from_source(self) -> None: + """ + Populate the lite `properties.pbr` dataclass from the rich + `pbr_source`'s `to_three_js_dict()` output. + + The lite dataclass is a strict subset of the Three.js + MeshPhysicalMaterial spec — fields on the rich source that + don't have a corresponding lite field (sheen, anisotropy, + iridescence, dispersion, etc.) are dropped. Downstream + consumers that need the full fidelity can still read + `material.pbr_source` directly or call + `material.to_three_js_material_dict()` which delegates to + the rich source first. + """ + if self.pbr_source is None: + return + try: + d = self.pbr_source.to_three_js_dict() + except Exception: + # Rich backend can't serialize — leave lite alone. + return + + lite = self.properties.pbr + if "color" in d and isinstance(d["color"], (list, tuple)) and len(d["color"]) >= 3: + r, g, b = d["color"][:3] + # Preserve alpha from existing base_color if rich didn't specify. + alpha = d.get("opacity", lite.base_color[3] if len(lite.base_color) >= 4 else 1.0) + lite.base_color = (r, g, b, alpha) + if "metalness" in d: + lite.metallic = d["metalness"] + if "roughness" in d: + lite.roughness = d["roughness"] + if "emissive" in d and isinstance(d["emissive"], (list, tuple)): + lite.emissive = tuple(d["emissive"]) + if "ior" in d: + lite.ior = d["ior"] + if "transmission" in d: + lite.transmission = d["transmission"] + if "clearcoat" in d: + lite.clearcoat = d["clearcoat"] + if "normalMap" in d: + lite.normal_map = d["normalMap"] + if "roughnessMap" in d: + lite.roughness_map = d["roughnessMap"] + if "metalnessMap" in d: + lite.metallic_map = d["metalnessMap"] + if "aoMap" in d: + lite.ambient_occlusion_map = d["aoMap"] + # ========================================================================= # Chaining API # ========================================================================= diff --git a/tests/test_pbr.py b/tests/test_pbr.py index 54f2003..7f97f8f 100644 --- a/tests/test_pbr.py +++ b/tests/test_pbr.py @@ -102,3 +102,115 @@ class NotPbr: pass assert not isinstance(NotPbr(), PbrSource) + + +class TestPbrBackfill: + """ + When `pbr_source` is set, the rich backend's `to_three_js_dict()` + output is projected onto the lite `properties.pbr` dataclass. This + lets existing ocp_vscode / downstream renderers that only read + `material.properties.pbr.` pick up the rich data without + needing adapter changes on their side. See ADR-0002 + the session + discussion on MorePET/mat#3. + """ + + def _make_rich_backend(self): + class RichBackend: + def to_three_js_dict(self) -> dict: + return { + "color": [0.91, 0.91, 0.88], + "metalness": 1.0, + "roughness": 0.08, + "ior": 2.5, + "transmission": 0.0, + "clearcoat": 0.2, + "emissive": [0.01, 0.01, 0.01], + "normalMap": "cache/normal.png", + "roughnessMap": "cache/roughness.png", + "metalnessMap": "cache/metalness.png", + "aoMap": "cache/ao.png", + } + + return RichBackend() + + def test_backfill_populates_lite_scalars(self): + steel = Material( + name="Brushed Steel", + density=7.85, + pbr_source=self._make_rich_backend(), + ) + lite = steel.properties.pbr + assert lite.base_color[:3] == (0.91, 0.91, 0.88) + assert lite.metallic == 1.0 + assert lite.roughness == 0.08 + assert lite.ior == 2.5 + assert lite.clearcoat == 0.2 + assert lite.emissive == (0.01, 0.01, 0.01) + + def test_backfill_populates_texture_maps(self): + steel = Material( + name="Brushed Steel", + density=7.85, + pbr_source=self._make_rich_backend(), + ) + lite = steel.properties.pbr + assert lite.normal_map == "cache/normal.png" + assert lite.roughness_map == "cache/roughness.png" + assert lite.metallic_map == "cache/metalness.png" + assert lite.ambient_occlusion_map == "cache/ao.png" + + def test_backfill_existing_adapter_compat(self): + """ + Simulate Bernhard's `_extract_materials_from_node` adapter: + read from `material.properties.pbr.` as he does today, + verify the rich-backend data makes it through. + """ + steel = Material( + name="Brushed Steel", + density=7.85, + pbr_source=self._make_rich_backend(), + ) + pbr = steel.properties.pbr + simulated_extraction = { + "color": pbr.base_color, + "metalness": pbr.metallic, + "roughness": pbr.roughness, + "normal_map": pbr.normal_map, + "roughness_map": pbr.roughness_map, + "metalness_map": pbr.metallic_map, + "ao_map": pbr.ambient_occlusion_map, + } + # Every field the adapter reads should carry the rich backend value. + assert simulated_extraction["color"][:3] == (0.91, 0.91, 0.88) + assert simulated_extraction["metalness"] == 1.0 + assert simulated_extraction["roughness"] == 0.08 + assert simulated_extraction["normal_map"] == "cache/normal.png" + assert simulated_extraction["metalness_map"] == "cache/metalness.png" + + def test_backfill_noop_when_no_source(self): + """With no pbr_source, lite dataclass keeps its authored values.""" + steel = Material( + name="Steel", + density=7.85, + pbr={"base_color": (0.5, 0.5, 0.5, 1.0), "metallic": 0.3}, + ) + assert steel.properties.pbr.base_color == (0.5, 0.5, 0.5, 1.0) + assert steel.properties.pbr.metallic == 0.3 + # Roughness stays at its dataclass default, not overridden. + assert steel.properties.pbr.roughness == 0.5 + + def test_rich_still_takes_precedence_in_dispatch(self): + """ + Even with backfill, `Material.to_three_js_material_dict()` still + prefers the rich `pbr_source` (full fidelity for callers that + can handle extra fields). + """ + rich = self._make_rich_backend() + steel = Material( + name="Brushed Steel", + density=7.85, + pbr_source=rich, + ) + out = steel.to_three_js_material_dict() + # Same object as rich's output, not re-serialized from lite. + assert out == rich.to_three_js_dict() diff --git a/uv.lock b/uv.lock index c190b4f..d81b16b 100644 --- a/uv.lock +++ b/uv.lock @@ -955,6 +955,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "materialx" +version = "1.39.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/f5/0d20693da8866c95eac48567df8f6da9bb89eefcfaf164b16ebd00b73430/materialx-1.39.4.tar.gz", hash = "sha256:652126537195beac2c63beff7891cb2a0f6b6340ff297831e851154961ec1943", size = 1579442, upload-time = "2025-09-15T19:12:14.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/e6/884ec686f84a1544b51b1cfa4426d4bf0505f2dab6b68538caf61399c590/materialx-1.39.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96dad106174630079c85d52b2aff7ec532904fb1b9969d770ee527ea74a9c57c", size = 4844030, upload-time = "2025-09-15T19:11:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bd/ef77d87f86710b8666effe712ee2d9f47d7040f595d277db7634262ca90b/materialx-1.39.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3822e33cf83d89e451c6f818d50eb7736c56a8e14bf8cf70d05a2ba129976012", size = 9157924, upload-time = "2025-09-15T19:11:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/c2/3904da5186887143b27d42cb9170a9ab11b9f6c78abd964ba80c4da1c1a9/materialx-1.39.4-cp310-cp310-win_amd64.whl", hash = "sha256:5a72babf94361b5f67e68a73e7e086217b6562011830e2fcef089e7d7caea2c8", size = 4890717, upload-time = "2025-09-15T19:11:48.26Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a1/1afe4f790f0dd0888014b710d48e02f4a4e2ff292f3fd21016d8494e22a7/materialx-1.39.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80a15166826033287895d950e48a05702ba7d607623fb351bfa0ac6f4e8db3f5", size = 4857065, upload-time = "2025-09-15T19:11:50.385Z" }, + { url = "https://files.pythonhosted.org/packages/97/86/4cdfd1e87856022da422301f6128d3285224bc9ec3a7539e9943b676ac2e/materialx-1.39.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0eadce374157a2079c2f6b86caae41a87981228f269e9468003a8e860ee514d4", size = 9174096, upload-time = "2025-09-15T19:11:52.417Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/fb0c0f5c7a867a4d4513660a0e01995219a1c817e219339412fd310303c8/materialx-1.39.4-cp311-cp311-win_amd64.whl", hash = "sha256:139670a2e00f70d9e1609cbdc5164be2f2553e02ef472e70e62f37b44ea3df1f", size = 4899166, upload-time = "2025-09-15T19:11:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/b06dfe28bcf1f2876581b450394faf13ec7d57a504443b13dacb5ddc708a/materialx-1.39.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:128bad7be5d816175b57d21afb59c16c550749aca5e352af1a31a5984478982b", size = 4856825, upload-time = "2025-09-15T19:11:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/45/d0/bade303a54b498ac644baf5895b57247cab3810776be2a8e98fa79081923/materialx-1.39.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0249deb21ca499910297a24ab317d7c70451fadcea4b080c63fa9879739207ed", size = 9164496, upload-time = "2025-09-15T19:11:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/e1/55/4204ec0a25678d71736b8015bff9593b6300ee832d3a572663a8403818fa/materialx-1.39.4-cp312-cp312-win_amd64.whl", hash = "sha256:889fb004f804ba65dff5d3087f3d48541048548dc39c832f1014592e1044c9fa", size = 4911350, upload-time = "2025-09-15T19:12:00.782Z" }, + { url = "https://files.pythonhosted.org/packages/f3/11/452712dbe124e2d9ae2cfc49a400646f5b9841b2e106b3825d451c0678a3/materialx-1.39.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91d3f66ff2b65d7d8945f6ce086551985b755753d0eb8b3fa95a9072d1155e89", size = 4857623, upload-time = "2025-09-15T19:12:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/799b4ab8b5dc115203c28dd293bad47d201673d667c06c99ec0757b5b945/materialx-1.39.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:77306def1c4039f77b2ebdecfda7bce8203a17a2eb6d9b471abe2ddd3573f0f5", size = 9165503, upload-time = "2025-09-15T19:12:04.645Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7b/1a16fac0a11cbb3aeb81841ddef67cabd5d4c0070218a284153384dc5ebc/materialx-1.39.4-cp313-cp313-win_amd64.whl", hash = "sha256:b52b7bb85704dfbc6f76ddce71421a0bf124782b70d9a39acce6973bcf91d056", size = 4911723, upload-time = "2025-09-15T19:12:07.631Z" }, +] + [[package]] name = "matplotlib" version = "3.10.8" @@ -1319,6 +1339,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/7b/0cf408c8c2bdf10685a284253e0004e6c672a9dbd23070d0889f4b54b284/ocpsvg-0.5.0-py3-none-any.whl", hash = "sha256:68cafdc3d681a1707530360baf2d51cfd58414b7d439f42eafbd31e842cf295e", size = 20789, upload-time = "2025-02-21T15:54:10.405Z" }, ] +[[package]] +name = "openexr" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c7/d6bb16bd2cd2d9c1e7a76790801f3794648a31163f275cebd7b2022dc896/openexr-3.4.9.tar.gz", hash = "sha256:9262e186472f16b489c05467d075baf47de3ff408cce0227fc02f410da704e5c", size = 25582278, upload-time = "2026-04-03T22:41:05.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/7e/4ae2315a4b997bbc10c87147011fd6c6b6b6cd3ead7738ab85f3455f56a8/openexr-3.4.9-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:41eea246d607509809055ceaa8d43e260161f8a7c8b022bc1774025e64bdee82", size = 2145465, upload-time = "2026-04-03T22:39:42.754Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/efddad81e728e88550340976975932ed04b24202dc463eb73d0457295b78/openexr-3.4.9-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:1589d4cef0efa7ea10662b8e85e3cde9b245f8cdd12a0acca3fcabffeae1e4d3", size = 1138519, upload-time = "2026-04-03T22:39:44.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/b9/785217a6da61ebd4216fc025122e3c54147aee81117bab2c64add1cd1849/openexr-3.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:56d765598f9350c2b25a1726dd790528429f31b968d11e530bf6b27498c21683", size = 1010774, upload-time = "2026-04-03T22:39:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e3/2e087f01faba46b9fc0a8a7f4dc40973dd6b82cada614bc8336ef122817a/openexr-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f8107f4cdcb400305547c8e057a3b419f11752a16936d9a99e7d4c0cf125593", size = 1161618, upload-time = "2026-04-03T22:39:47.984Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/a5b87ff7a1e5ba3c1bda4c8eb8c8b199e7e7f9cafbeb478d0928571b2873/openexr-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9b5a27e0acdb5e9ade0f161435d1362732f9fd4acf066622a993d11491359761", size = 1291867, upload-time = "2026-04-03T22:39:49.593Z" }, + { url = "https://files.pythonhosted.org/packages/77/a6/4ed19c2305f959e7dc5d46a024bf3db44d316e63c27e166387a3fa1afc09/openexr-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:032fdb8f78874b4fec21078a741acc8f414995c96296cd19a191ee6c4a05a857", size = 2149596, upload-time = "2026-04-03T22:39:51.275Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fb/c65602dc78c8f57bb0558af40546a4f905862e32a1735011b38126dd859f/openexr-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b54936434965a3ad6ed33d833644f42ef63d4f59fd117e217b8db8869f80560c", size = 2329797, upload-time = "2026-04-03T22:39:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/9d/02/c4eea1963964a557693c27fd3e51eab369ca80f0783619b238b95c5756ee/openexr-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:bc9a960f37660324246da8a4137a67d457cbc068a16460e707a48a772539469b", size = 732875, upload-time = "2026-04-03T22:39:54.784Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/df33dc3ec7a22fe94bd927137cbedd2800ecb5c829f003258a78c8a81ea9/openexr-3.4.9-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:0ee041a1b70ade86d54c83d168e51e72353d5e857b90e117deab8080f055d196", size = 2148493, upload-time = "2026-04-03T22:39:56.284Z" }, + { url = "https://files.pythonhosted.org/packages/35/f2/3ed6b9c3e742c6200d8d5ee83ad9eea8035fe653794b0d0494ca50b73c70/openexr-3.4.9-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:a195d198f0c80bd5234f37315d86df5ee51753b3ea17ff43e9723640232a5445", size = 1139970, upload-time = "2026-04-03T22:39:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/79/a9/84e764804f84450ed313305c6c1137f4046a25d6759f20da050ebf523f56/openexr-3.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:426398d8772e3a49c7d932bdf4aef5894d31b391bac3f015bd19345ef0a5f660", size = 1011970, upload-time = "2026-04-03T22:39:59.725Z" }, + { url = "https://files.pythonhosted.org/packages/8b/07/683568856389acca16972ab8190dcc46e736b9ffec035b069c6174494ee0/openexr-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fac9fd4e165461ac0138de48cf682f3730f3e35c76747d21aa85b3ebfda802bd", size = 1162879, upload-time = "2026-04-03T22:40:01.244Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/d84c31ecf12e5f37917ac43bbe0ac982cb8593c573bc0548a35b13f96bba/openexr-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:865df41542a1cad1389849c1640ac592571764359f1d089bfe57dfc3709430fb", size = 1292944, upload-time = "2026-04-03T22:40:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/1b4d9f8949dd188980ea9af12f857496775260e00cab1965f5ca0ef186e4/openexr-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:345b346d4115590160998bbb6399b89906d3753a4cdaf52c16b381bda3f276d0", size = 2150454, upload-time = "2026-04-03T22:40:04.191Z" }, + { url = "https://files.pythonhosted.org/packages/31/de/6a1e616e351d424831f7d1804e1f79595a70f8795ff63487dc81978b8316/openexr-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d7f5a44585daccae4e2787b2f7af59ac00838764f09a0a1ad5cd3451b520f76d", size = 2330749, upload-time = "2026-04-03T22:40:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/c6/95/840b48252cf28b3430187581facc399154ace45b4a13ca4372ed0ee91347/openexr-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:f7fae5fe9164f6153c3ca3235440bba38b0c1f01a9fc5e86c63b2c76647f0678", size = 733537, upload-time = "2026-04-03T22:40:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/99/f5/724ffb39cc4bec7f8c205b041c0eab4fce219e4ca14c641324f2d32b35ed/openexr-3.4.9-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:3a590eec7d0088e7d15cb307f0b4cb04cdb11e3a6a455818cd2f00fe63a38286", size = 2152583, upload-time = "2026-04-03T22:40:09.601Z" }, + { url = "https://files.pythonhosted.org/packages/98/d2/e04d82d4e816e285de7f29e9e2496946d671aa8f8c640fd3cf681c799af0/openexr-3.4.9-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fcbb5074a4f239f6d5c147671c09840cd06762aa1253c6a55ea5be2b1de13956", size = 1143106, upload-time = "2026-04-03T22:40:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/46/be/e86167c1588f3738b58728e1d4d33140f5dc168bf4ad5dd61bec457c54c3/openexr-3.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b0eb5bfdb076805f09d93cc1264aff62345b0174c8ca0be6be2e90b6788049d", size = 1017974, upload-time = "2026-04-03T22:40:13.127Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a6/1247078abf55c86056b3be1dee59c05b2b1c5abcedc2b353343fa8a99098/openexr-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9e7144b64e663abd441f96e2b3f1bcd5774d982d3eaab705f6dc6180361e3e01", size = 1162574, upload-time = "2026-04-03T22:40:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/91/b6/d00a899567c779413d00e7fa60dc252075c8f645342802ffec3888d571c9/openexr-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee08fb7e386e4f41f15b628479d20eeaf9b7145af6c57cfd1d20149c1cd93aef", size = 1292780, upload-time = "2026-04-03T22:40:16.481Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/4911f0e6f9b1e5a964ca8e82f3b108bd1b8c004fbb9a26e06db18a7a45c8/openexr-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f1e7f1e66d727278670903d83e9b432b593412969e741ecd6ba29895b4289944", size = 2151835, upload-time = "2026-04-03T22:40:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/6cc3cf1aae0361ffe394b683852b7029a794854183a5176dec2468f34729/openexr-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93c52c495b39fec399a820d717073bde0a8e589b05eb68456993ad9199f418b1", size = 2333724, upload-time = "2026-04-03T22:40:20.422Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/4a86426c7036b5309b6262c7193f48261bc8d6aeac1bd7274b9e143dda16/openexr-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:392d90282cfd3185302455562434b5e627a0cf01f645892691712c6f576b8beb", size = 735151, upload-time = "2026-04-03T22:40:22.303Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f7/5d4403fd5562f91c02f76f852a707852a256436f2cf9c36246ca3c6edf51/openexr-3.4.9-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:639f612f6a7007dda6b87ad161223531f0006b231a2d07ef6d0c18ef8d877eef", size = 2152506, upload-time = "2026-04-03T22:40:24.084Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f9/a46113ce35579f1cbe910b43be9f53dea658369edf8d5cbb2bf30e95db2b/openexr-3.4.9-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:495f0b0cef6850d3810bb615f2363ee483809fc068f39d77d15106f76f9f24cb", size = 1142985, upload-time = "2026-04-03T22:40:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/33/95/be6cc1f595ff48b5750d3d387f58a13e0cfa8d6ba198b1a2188c71a96b9b/openexr-3.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b59d5b29101a8ee59a183d2751dd99b8a6278c3dba2be3fef530ec2ddad02fd", size = 1017957, upload-time = "2026-04-03T22:40:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/9a/96/f3abc015cced773df5ca003493591cb7330b6ad53207ab96126105d73c9d/openexr-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6f73e3d7ac89713008bf02356c9138e43b90a94b94c15d966a843285177b45bc", size = 1162861, upload-time = "2026-04-03T22:40:29.317Z" }, + { url = "https://files.pythonhosted.org/packages/4a/be/03728ca8e1a8eaa8bed27c72aab93de7d85eadf8b29e60b9eeb1482c854d/openexr-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5150ac0ddb328b49cfdbabc66cb2a2bd136bf45022f2de959461e4699dfb8a87", size = 1292825, upload-time = "2026-04-03T22:40:30.855Z" }, + { url = "https://files.pythonhosted.org/packages/f9/49/1eee081a599434b40803f794301fdebe4ff8554a7e52c3d15ae9d179f28c/openexr-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8c1025479b71b4641ef872b2e0086c53a882bbb5bf58a0890760318d67a0802", size = 2151693, upload-time = "2026-04-03T22:40:32.609Z" }, + { url = "https://files.pythonhosted.org/packages/9f/91/4b85a65d4a1bd45023a1125a4b20d48171b14290a13da40e1d5f6d08d6d4/openexr-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:483d2ae18c6b7a4a7e148cc627a066dfdebc03bd80e115d75f403cffcafab3fe", size = 2333559, upload-time = "2026-04-03T22:40:34.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f9/04dfadf8eec3a1acb3aa4523c31273eec8cde74a7947929ff591f09e7e20/openexr-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:70f652c517fe38bcccd5a1146266e33e613c735e8cf6013659c0494bbe1485e2", size = 735105, upload-time = "2026-04-03T22:40:35.964Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -1817,7 +1881,7 @@ matproj = [ { name = "pymatgen", version = "2026.3.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] pbr = [ - { name = "threejs-materials" }, + { name = "threejs-materials", extra = ["materialx"] }, ] periodictable = [ { name = "periodictable" }, @@ -1841,7 +1905,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "threejs-materials", marker = "extra == 'all'", specifier = ">=1.0.0" }, - { name = "threejs-materials", marker = "extra == 'pbr'", specifier = ">=1.0.0" }, + { name = "threejs-materials", extras = ["materialx"], marker = "extra == 'pbr'", specifier = ">=1.0.0" }, { name = "tomli", marker = "python_full_version == '3.10.*'", specifier = ">=1.0.0" }, ] provides-extras = ["periodictable", "matproj", "build123d", "pbr", "dev", "all"] @@ -2377,6 +2441,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/3a/59d0ad302eb4a5ca891b40c8ce1916690737b714d52f100ca696f23150d3/threejs_materials-1.0.4-py3-none-any.whl", hash = "sha256:d38e27dca790996e9599123e554b8d8186e616d458bf308b5d2f64c47451642e", size = 57526, upload-time = "2026-04-09T12:04:58.803Z" }, ] +[package.optional-dependencies] +materialx = [ + { name = "materialx" }, + { name = "openexr" }, +] + [[package]] name = "tomli" version = "2.4.1" From 324c7f85d081a8f3b82f870e98b0fb6b17306db4 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 16:37:37 +0200 Subject: [PATCH 5/6] fix(pbr): backfill handles threejs_materials' nested to_dict() shape threejs_materials.PbrProperties.to_dict() returns a nested dict: { "id": "...", "name": "...", "source": "...", "url": "...", "license": "...", "values": {color: [...], metalness: ..., roughness: ..., ...}, "textures": {normal: "...", roughness: "...", ...}, } not a flat Three.js MeshPhysicalMaterial dict. My first backfill pass assumed the flat shape, which worked against stub backends in the unit tests but failed at runtime against the real library with an AttributeError on `to_three_js_dict`. This change: - Detects the shape at runtime via `isinstance(d.get("values"), dict)` and picks `values` for scalars, `textures` for maps when nested. Falls back to reading top-level keys when flat (so the native lite `PBRProperties.to_dict()` flat output still works). - Uses short-form map names (`normal`, `roughness`, `metalness`, `ao`) that threejs-materials' PbrMaps dataclass emits, with a fallback to the camelCase `normalMap`/`roughnessMap`/etc. for the flat shape. Verified end-to-end with a real threejs_materials.PbrProperties: >>> from threejs_materials import PbrProperties >>> rich = PbrProperties.create('Steel', color=[.91,.91,.88], metalness=1, roughness=.08) >>> steel = Material(name='Steel', pbr_source=rich) >>> steel.properties.pbr.base_color (0.91, 0.91, 0.88, 1.0) >>> steel.properties.pbr.metallic 1.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...br-via-threejs-materials-optional-extra.md | 10 +- src/pymat/core.py | 92 ++++++++++++------- src/pymat/pbr/_protocol.py | 6 +- src/pymat/properties.py | 2 +- tests/test_pbr.py | 18 ++-- 5 files changed, 77 insertions(+), 51 deletions(-) diff --git a/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md index 85a1873..42dfecb 100644 --- a/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md +++ b/docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md @@ -54,7 +54,7 @@ Concretely: # pymat/pbr/_protocol.py @runtime_checkable class PbrSource(Protocol): - def to_three_js_dict(self) -> dict: ... + def to_dict(self) -> dict: ... # pymat/pbr/__init__.py try: @@ -73,8 +73,8 @@ class _MaterialInternal: def to_three_js_material_dict(self) -> dict: """Pick the right backend and emit Three.js JSON.""" if self.pbr_source is not None: - return self.pbr_source.to_three_js_dict() - return self.properties.pbr.to_three_js_dict() + return self.pbr_source.to_dict() + return self.properties.pbr.to_dict() ``` Usage: @@ -104,7 +104,7 @@ serialized fields onto the lite `properties.pbr` dataclass** via an internal `_backfill_pbr_from_source()` pass in `__post_init__`. This copies the overlapping fields (color, metalness, roughness, ior, emissive, transmission, clearcoat, normal/roughness/metalness/ao -maps) from `pbr_source.to_three_js_dict()` into the lite dataclass +maps) from `pbr_source.to_dict()` into the lite dataclass one-way at construction time. Why: existing downstream renderers that read @@ -165,7 +165,7 @@ the backfill. - **Two parallel PBR code paths** on py-materials' side. The lite `PBRProperties` dataclass stays (for TOML-authored materials and - users without the extra) and grows a `to_three_js_dict()` + users without the extra) and grows a `to_dict()` method that implements the Protocol. The rich path is the optional extra. Some duplication of intent between the two serializers is unavoidable; their outputs may drift on edge diff --git a/src/pymat/core.py b/src/pymat/core.py index 486ef20..244f9a9 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -212,51 +212,77 @@ def __post_init__(self): def _backfill_pbr_from_source(self) -> None: """ Populate the lite `properties.pbr` dataclass from the rich - `pbr_source`'s `to_three_js_dict()` output. + `pbr_source`'s `to_dict()` output. + + Supports two `to_dict()` shapes, detected at runtime: + + - **Nested** (`threejs_materials.PbrProperties` format): + ``{id, name, source, url, license, values: {...}, textures: {...}}`` + — scalars under ``values``, maps under ``textures``. + - **Flat** (`pymat`'s own lite `PBRProperties.to_dict()` format): + ``{color, metalness, roughness, normalMap, ...}`` — Three.js + MeshPhysicalMaterial camelCase keys at the top level. The lite dataclass is a strict subset of the Three.js MeshPhysicalMaterial spec — fields on the rich source that don't have a corresponding lite field (sheen, anisotropy, iridescence, dispersion, etc.) are dropped. Downstream - consumers that need the full fidelity can still read - `material.pbr_source` directly or call - `material.to_three_js_material_dict()` which delegates to - the rich source first. + consumers that need full fidelity should read + `material.pbr_source` directly. """ if self.pbr_source is None: return try: - d = self.pbr_source.to_three_js_dict() + d = self.pbr_source.to_dict() except Exception: - # Rich backend can't serialize — leave lite alone. return + # Detect shape: nested has `values` key, flat has top-level scalars. + if isinstance(d.get("values"), dict): + values = d["values"] + maps = d.get("textures") if isinstance(d.get("textures"), dict) else {} + else: + values = d + maps = {} + lite = self.properties.pbr - if "color" in d and isinstance(d["color"], (list, tuple)) and len(d["color"]) >= 3: - r, g, b = d["color"][:3] - # Preserve alpha from existing base_color if rich didn't specify. - alpha = d.get("opacity", lite.base_color[3] if len(lite.base_color) >= 4 else 1.0) + + # Scalars + color = values.get("color") + if isinstance(color, (list, tuple)) and len(color) >= 3: + r, g, b = color[:3] + alpha = values.get( + "opacity", + lite.base_color[3] if len(lite.base_color) >= 4 else 1.0, + ) lite.base_color = (r, g, b, alpha) - if "metalness" in d: - lite.metallic = d["metalness"] - if "roughness" in d: - lite.roughness = d["roughness"] - if "emissive" in d and isinstance(d["emissive"], (list, tuple)): - lite.emissive = tuple(d["emissive"]) - if "ior" in d: - lite.ior = d["ior"] - if "transmission" in d: - lite.transmission = d["transmission"] - if "clearcoat" in d: - lite.clearcoat = d["clearcoat"] - if "normalMap" in d: - lite.normal_map = d["normalMap"] - if "roughnessMap" in d: - lite.roughness_map = d["roughnessMap"] - if "metalnessMap" in d: - lite.metallic_map = d["metalnessMap"] - if "aoMap" in d: - lite.ambient_occlusion_map = d["aoMap"] + if "metalness" in values: + lite.metallic = values["metalness"] + if "roughness" in values: + lite.roughness = values["roughness"] + emissive = values.get("emissive") + if isinstance(emissive, (list, tuple)): + lite.emissive = tuple(emissive) + if "ior" in values: + lite.ior = values["ior"] + if "transmission" in values: + lite.transmission = values["transmission"] + if "clearcoat" in values: + lite.clearcoat = values["clearcoat"] + + # Texture maps (nested: `textures` dict keys are short names like + # `normal`, `roughness`, `metalness`, `ao`. Flat: `normalMap`, + # `roughnessMap`, `metalnessMap`, `aoMap` camelCase.) + lite.normal_map = maps.get("normal") or values.get("normalMap") or lite.normal_map + lite.roughness_map = ( + maps.get("roughness") or values.get("roughnessMap") or lite.roughness_map + ) + lite.metallic_map = ( + maps.get("metalness") or values.get("metalnessMap") or lite.metallic_map + ) + lite.ambient_occlusion_map = ( + maps.get("ao") or values.get("aoMap") or lite.ambient_occlusion_map + ) # ========================================================================= # Chaining API @@ -526,8 +552,8 @@ def to_three_js_material_dict(self) -> dict: lite native in-tree backend). See ADR-0002. """ if self.pbr_source is not None: - return self.pbr_source.to_three_js_dict() - return self.properties.pbr.to_three_js_dict() + return self.pbr_source.to_dict() + return self.properties.pbr.to_dict() def __repr__(self) -> str: """String representation showing path and density.""" diff --git a/src/pymat/pbr/_protocol.py b/src/pymat/pbr/_protocol.py index 7af561a..1ff9494 100644 --- a/src/pymat/pbr/_protocol.py +++ b/src/pymat/pbr/_protocol.py @@ -9,12 +9,12 @@ class PbrSource(Protocol): """A renderable PBR material source. - Any object implementing `to_three_js_dict()` is a valid `PbrSource`. + Any object implementing `to_dict()` is a valid `PbrSource`. The native `pymat.properties.PBRProperties` and the optional `threejs_materials.PbrProperties` both conform. Consumers (ocp_vscode, build123d viewers, custom renderers) should - call `to_three_js_dict()` to get a Three.js `MeshPhysicalMaterial`- + call `to_dict()` to get a Three.js `MeshPhysicalMaterial`- shaped dict, agnostic of which backend is providing the data. This keeps the rendering pipeline decoupled from how the material was sourced (TOML authored / runtime downloaded / MaterialX baked). @@ -22,7 +22,7 @@ class PbrSource(Protocol): See ADR-0002 for the design rationale. """ - def to_three_js_dict(self) -> dict: + def to_dict(self) -> dict: """Return a Three.js `MeshPhysicalMaterial` dict. Conforming implementations should use camelCase keys matching diff --git a/src/pymat/properties.py b/src/pymat/properties.py index 246f6fb..b0dcadc 100644 --- a/src/pymat/properties.py +++ b/src/pymat/properties.py @@ -329,7 +329,7 @@ class PBRProperties: metallic_map: Optional[str] = None ambient_occlusion_map: Optional[str] = None - def to_three_js_dict(self) -> dict: + def to_dict(self) -> dict: """ Serialize to a Three.js ``MeshPhysicalMaterial`` dict. diff --git a/tests/test_pbr.py b/tests/test_pbr.py index 7f97f8f..b937571 100644 --- a/tests/test_pbr.py +++ b/tests/test_pbr.py @@ -1,7 +1,7 @@ """ Tests for `pymat.pbr` — PbrSource Protocol + native backend serializer. -Covers the lite in-tree path (PBRProperties.to_three_js_dict) and the +Covers the lite in-tree path (PBRProperties.to_dict) and the Material.to_three_js_material_dict dispatch. The rich threejs-materials backend is exercised via a duck-typed stub to avoid pulling the extra into the base test dependency set. See ADR-0002. @@ -27,7 +27,7 @@ def test_native_minimal_serialization(self): """Default PBRProperties emits only `color` (all other fields are at Three.js defaults and get omitted).""" pbr = PBRProperties() - out = pbr.to_three_js_dict() + out = pbr.to_dict() assert out == {"color": [0.8, 0.8, 0.8]} def test_native_full_serialization(self): @@ -39,7 +39,7 @@ def test_native_full_serialization(self): clearcoat=0.5, normal_map="/path/to/normal.png", ) - out = pbr.to_three_js_dict() + out = pbr.to_dict() assert out["color"] == [0.7, 0.2, 0.2] assert out["metalness"] == 1.0 assert out["roughness"] == 0.25 @@ -69,7 +69,7 @@ def test_pbr_source_takes_precedence(self): class FakeRichBackend: """Stub conforming to PbrSource Protocol.""" - def to_three_js_dict(self) -> dict: + def to_dict(self) -> dict: return { "color": [0.91, 0.91, 0.88], "metalness": 1.0, @@ -89,10 +89,10 @@ def to_three_js_dict(self) -> dict: assert out["normalMap"] == "textures/brushed_steel_normal.png" def test_pbr_source_stub_is_pbr_source(self): - """Any class with `to_three_js_dict()` satisfies the Protocol.""" + """Any class with `to_dict()` satisfies the Protocol.""" class Stub: - def to_three_js_dict(self): + def to_dict(self): return {} assert isinstance(Stub(), PbrSource) @@ -106,7 +106,7 @@ class NotPbr: class TestPbrBackfill: """ - When `pbr_source` is set, the rich backend's `to_three_js_dict()` + When `pbr_source` is set, the rich backend's `to_dict()` output is projected onto the lite `properties.pbr` dataclass. This lets existing ocp_vscode / downstream renderers that only read `material.properties.pbr.` pick up the rich data without @@ -116,7 +116,7 @@ class TestPbrBackfill: def _make_rich_backend(self): class RichBackend: - def to_three_js_dict(self) -> dict: + def to_dict(self) -> dict: return { "color": [0.91, 0.91, 0.88], "metalness": 1.0, @@ -213,4 +213,4 @@ def test_rich_still_takes_precedence_in_dispatch(self): ) out = steel.to_three_js_material_dict() # Same object as rich's output, not re-serialized from lite. - assert out == rich.to_three_js_dict() + assert out == rich.to_dict() From 8c7729cee10f31848011c1cf83bfb29013745110 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 17:55:03 +0200 Subject: [PATCH 6/6] feat(pbr): add base_color_map field for albedo textures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite `PBRProperties` dataclass previously had fields for `normal_map`, `roughness_map`, `metallic_map`, and `ambient_occlusion_map` but no `base_color_map`. This meant the PRIMARY texture channel — the albedo/diffuse map that carries most of the visual identity for wood, bricks, tiles, stone, and most non-metal PBR materials — was silently dropped during backfill from a rich `pbr_source`. Metals accidentally survived because their visual character comes from `metalness=1` + `roughness` + environment reflection, not an albedo map. Non-metal materials rendered as flat white. Changes: - `PBRProperties.base_color_map: Optional[str] = None` (new field). - `PBRProperties.to_dict()` emits it as `map` (Three.js's name for the color channel is plain `map`, not `colorMap` / `albedoMap`). - `_backfill_pbr_from_source()` reads it from both the nested `{textures: {color: ...}}` shape (threejs_materials v1) and the flat `{map: ...}` shape (native lite output). - New test `test_backfill_handles_nested_threejs_shape` captures a realistic fixture matching threejs_materials' real output (verified against the live library at v1.0.4). Note: this fix alone isn't sufficient for ocp_vscode rendering — Bernhard's `_extract_materials_from_node` reads texture fields from `pbr.normal_map`/etc. but never from an albedo field, so the map never makes it to `PbrProperties.create()`. The complete fix is the companion PR on bernhard-42/vscode-ocp-cad-viewer#228 which bypasses the lossy field-copy entirely when `pbr_source` is set. This base_color_map field still lands for completeness and for any future downstream consumer that does read the lite dataclass directly. Refs MorePET/mat#3 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pymat/core.py | 12 ++++----- src/pymat/properties.py | 3 +++ tests/test_pbr.py | 58 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/pymat/core.py b/src/pymat/core.py index 244f9a9..016fca9 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -270,16 +270,16 @@ def _backfill_pbr_from_source(self) -> None: if "clearcoat" in values: lite.clearcoat = values["clearcoat"] - # Texture maps (nested: `textures` dict keys are short names like - # `normal`, `roughness`, `metalness`, `ao`. Flat: `normalMap`, - # `roughnessMap`, `metalnessMap`, `aoMap` camelCase.) + # Texture maps. Nested (`textures`) keys are short names like + # `color`, `normal`, `roughness`, `metalness`, `ao`. Flat + # format uses Three.js camelCase: `map` (the color channel), + # `normalMap`, `roughnessMap`, `metalnessMap`, `aoMap`. + lite.base_color_map = maps.get("color") or values.get("map") or lite.base_color_map lite.normal_map = maps.get("normal") or values.get("normalMap") or lite.normal_map lite.roughness_map = ( maps.get("roughness") or values.get("roughnessMap") or lite.roughness_map ) - lite.metallic_map = ( - maps.get("metalness") or values.get("metalnessMap") or lite.metallic_map - ) + lite.metallic_map = maps.get("metalness") or values.get("metalnessMap") or lite.metallic_map lite.ambient_occlusion_map = ( maps.get("ao") or values.get("aoMap") or lite.ambient_occlusion_map ) diff --git a/src/pymat/properties.py b/src/pymat/properties.py index b0dcadc..03fd8a9 100644 --- a/src/pymat/properties.py +++ b/src/pymat/properties.py @@ -324,6 +324,7 @@ class PBRProperties: clearcoat: float = 0.0 # secondary glossy layer # Texture maps (paths or URIs) + base_color_map: Optional[str] = None # albedo / diffuse texture normal_map: Optional[str] = None roughness_map: Optional[str] = None metallic_map: Optional[str] = None @@ -362,6 +363,8 @@ def to_dict(self) -> dict: out["opacity"] = a out["transparent"] = True # Texture maps — keys use Three.js naming (normalMap, etc.). + if self.base_color_map is not None: + out["map"] = self.base_color_map # Three.js color channel is plain `map` if self.normal_map is not None: out["normalMap"] = self.normal_map if self.roughness_map is not None: diff --git a/tests/test_pbr.py b/tests/test_pbr.py index b937571..8ccd5c8 100644 --- a/tests/test_pbr.py +++ b/tests/test_pbr.py @@ -115,6 +115,9 @@ class TestPbrBackfill: """ def _make_rich_backend(self): + """Flat Three.js-shaped dict (matches the native lite backend's + output shape).""" + class RichBackend: def to_dict(self) -> dict: return { @@ -125,6 +128,7 @@ def to_dict(self) -> dict: "transmission": 0.0, "clearcoat": 0.2, "emissive": [0.01, 0.01, 0.01], + "map": "cache/albedo.png", "normalMap": "cache/normal.png", "roughnessMap": "cache/roughness.png", "metalnessMap": "cache/metalness.png", @@ -133,6 +137,34 @@ def to_dict(self) -> dict: return RichBackend() + def _make_nested_rich_backend(self): + """Nested `{values, textures}` dict shape — what + `threejs_materials.PbrProperties.to_dict()` actually emits + (verified against the real library at v1.0.4).""" + + class NestedBackend: + def to_dict(self) -> dict: + return { + "id": "ivory_walnut", + "name": "Ivory Walnut Solid Wood", + "source": "gpuopen", + "url": "https://matlib.gpuopen.com/...", + "license": "MIT Public Domain", + "values": { + "color": [0.8, 0.8, 0.8], # neutral tint, map carries visual + "metalness": 0.0, + "roughness": 1.0, + "ior": 1.5, + }, + "textures": { + "color": "data:image/png;base64,AAAA", # albedo + "normal": "data:image/png;base64,BBBB", + "roughness": "data:image/png;base64,CCCC", + }, + } + + return NestedBackend() + def test_backfill_populates_lite_scalars(self): steel = Material( name="Brushed Steel", @@ -154,11 +186,37 @@ def test_backfill_populates_texture_maps(self): pbr_source=self._make_rich_backend(), ) lite = steel.properties.pbr + assert lite.base_color_map == "cache/albedo.png" assert lite.normal_map == "cache/normal.png" assert lite.roughness_map == "cache/roughness.png" assert lite.metallic_map == "cache/metalness.png" assert lite.ambient_occlusion_map == "cache/ao.png" + def test_backfill_handles_nested_threejs_shape(self): + """ + `threejs_materials.PbrProperties.to_dict()` emits a nested + `{id, name, source, url, license, values: {...}, textures: {...}}` + dict, NOT a flat Three.js MeshPhysicalMaterial dict. The + backfill must read scalars from `values` and texture paths + from `textures`. See ADR-0002 + the live verification on + the build123d fork (gerchowl:feature/pymat-material-integration). + """ + wood = Material( + name="Walnut", + density=0.65, + pbr_source=self._make_nested_rich_backend(), + ) + lite = wood.properties.pbr + # Scalars from `values`. + assert lite.base_color[:3] == (0.8, 0.8, 0.8) + assert lite.metallic == 0.0 + assert lite.roughness == 1.0 + assert lite.ior == 1.5 + # Texture maps from `textures` (short names: color, normal, ...). + assert lite.base_color_map == "data:image/png;base64,AAAA" + assert lite.normal_map == "data:image/png;base64,BBBB" + assert lite.roughness_map == "data:image/png;base64,CCCC" + def test_backfill_existing_adapter_compat(self): """ Simulate Bernhard's `_extract_materials_from_node` adapter: