From 6d81a1925410d4a4db850bf1c3b6a2c64231238f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 15 Apr 2026 20:57:23 +0200 Subject: [PATCH 01/32] docs: refresh README for 2.1.0 (molar_mass, PyPI install, fixed URLs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README was written before 2.1.0 shipped and was stale in several places. This refresh brings it current with what's actually on PyPI today. ## Template changes (docs/README_TEMPLATE.md) - **Installation**: PyPI (`pip install py-materials`) is now the recommended path. Git+https is documented as secondary for the main-branch dev build. The `[periodictable]`, `[build123d]`, and `[all]` optional extras are all mentioned with their install lines. - **Features list** — added: - Formula parsing + molar mass (with Python ↔ Rust parity note) - Explicit Python 3.10–3.13 support statement - Pure-element density only note on `periodictable` integration (the "compound density" promise was always broken; see #9) - **Repo URLs fixed**: `MorePet/py-mat` / `MorePET/py-mat` → `MorePET/mat` (the repo was renamed but the template never caught up). - **Links section** now includes PyPI and the rs-materials crates.io page alongside the GitHub / Issues links. - **Enrichment example** rewritten to match current behavior: pure elements get density from `periodictable`, compounds only get `composition` populated. Molar mass works for both via the computed `Material.molar_mass` property. Previous example promised `print(material.density) # ~3.95 g/cm³` for Al2O3 which never worked — the bug fixed in #9. - **New "Design Decisions (ADRs)" section** linking `docs/decisions/0001-derived-chemistry-properties-live-on-material.md`, so contributors can find the rationale behind `Material.molar_mass` being a computed property. ## Test additions (tests/test_readme_examples.py) Two new methods in `TestBasicUsage`, so they show up in the README's Quick Start section AND stay verified by CI: - `test_molar_mass_from_formula` — shows `Material.molar_mass` on a pure element, simple compound, fractional-stoichiometry compound (LYSO), dopant-suffix stripping (LYSO:Ce), the Pint-wrapped `molar_mass_qty`, and the `None` fallback when no formula is set. - `test_elements_low_level_api` — shows the `pymat.elements` module direct API (`ATOMIC_WEIGHT`, `parse_formula`, `compute_molar_mass`) for Monte Carlo transport callers who want stoichiometry without wrapping in a full `Material`. Notes the line-for-line Rust rs-materials parity. ## Generator fixes (scripts/generate_readme.py) Two pre-existing bugs surfaced while regenerating: 1. **Code block indentation was broken** — the generator extracted test function bodies but never dedented. Every code block in the generated README had an 8-space indent on every line except the first (the `from pymat import ...` line, extracted from a different regex branch, started at column 0 while the rest of the body started at column 8). Result: uncopy-pasteable examples. Fixed via `textwrap.dedent()` on both docstring and code extracts. 2. **Assertions were stripped wholesale** — the previous `re.sub(r"\s*assert .*?\n", "", code)` removed all `assert` lines to make examples "cleaner", but it removed exactly the lines that show the reader the expected return values. After the fix, readers see e.g. `assert iron.molar_mass == 55.85` alongside the computation instead of having to guess. `pytest.importorskip` calls still get filtered because those ARE noise in a user- facing example. ## README.md Regenerated. 580 lines, 19 examples, code blocks now dedented and assertion-bearing. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 522 ++++++++++++++++++++++------------ docs/README_TEMPLATE.md | 61 +++- scripts/generate_readme.py | 67 ++++- tests/test_readme_examples.py | 72 +++++ 4 files changed, 509 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 4368fbe..e028a90 100644 --- a/README.md +++ b/README.md @@ -1,338 +1,463 @@ -# mat +# pymat -Material database for CAD and Monte Carlo particle transport. - -Shared TOML data files with language-specific packages: - -| Package | Language | Registry | Import | -|---------|----------|----------|--------| -| [py-materials](https://pypi.org/project/py-materials/) | Python | PyPI | `import pymat` | -| [rs-materials](mat-rs/) | Rust | crates.io | `use rs_materials::...` | +A hierarchical material library for CAD applications and Monte Carlo particle transport, with build123d integration. ## Features -### Python (py-materials) - - **Hierarchical Materials**: Chain grades, tempers, treatments, and vendors - **Property Inheritance**: Children inherit parent properties unless overridden - **Lazy Loading**: Categories load on first access +- **TOML Data Storage**: Easy-to-edit material definitions +- **Formula Parsing + Molar Mass**: Computed from the chemical formula via `Material.molar_mass`, with fractional stoichiometry (`Lu1.8Y0.2SiO5`) and dopant suffix stripping (`LYSO:Ce`). Atomic weights mirror the Rust `rs-materials` crate for Python ↔ Rust parity. - **build123d Integration**: Apply materials to shapes with automatic mass calculation - **PBR Rendering**: Physically-based rendering properties for visualization -- **periodictable Integration**: Auto-fill density from chemical formulas +- **periodictable Integration**: Auto-fill composition from chemical formulas for compounds; auto-fill density for pure elements - **Factory Functions**: Temperature/pressure-dependent materials (water, air, saline) - -### Rust (rs-materials) - -- **TOML Database Reader**: Loads all 7 material categories with property inheritance -- **Formula Parser**: Fractional stoichiometry (`Lu1.8Y0.2SiO5` → element/count pairs) -- **Mass/Atom Fraction Conversion**: Bidirectional with roundtrip correctness -- **Scintillator Properties**: Light yield, decay time, emission peak, refractive index -- **Thread-Safe**: `Send + Sync` for `Arc` sharing in multi-threaded transport engines +- **Separation of Concerns**: Optical properties (physics) separate from PBR (visualization) +- **Python 3.10 – 3.13 supported**, core library depends only on `pint` ## Installation -### Python - ```bash -# With uv (recommended) -uv add git+https://github.com/MorePET/mat.git@latest - -# Or specify version -uv add git+https://github.com/MorePET/mat.git@v1.0.0 -``` +# From PyPI (recommended) +pip install py-materials +# or: uv add py-materials -### Rust +# From the main branch (development) +pip install git+https://github.com/MorePET/mat.git@main -```toml -[dependencies] -rs-materials = { git = "https://github.com/MorePET/mat.git" } +# With optional extras +pip install "py-materials[periodictable]" # auto-fill from chemical formulas +pip install "py-materials[build123d]" # build123d Shape integration (Python <= 3.12) +pip install "py-materials[all]" # everything above ``` ## Quick Start ## Creating Materials - Create materials with convenient parameters: +Create materials with convenient parameters: ```python from pymat import Material - # Using convenience parameters - steel = Material(name="Steel", density=7.85) - # With visualization color - aluminum = Material(name="Aluminum", density=2.7, color=(0.88, 0.88, 0.88)) - # With formula - lyso = Material(name="LYSO", formula="Lu1.8Y0.2SiO5", density=7.1) - assert lyso.formula == "Lu1.8Y0.2SiO5" +# Using convenience parameters +steel = Material(name="Steel", density=7.85) +assert steel.density == 7.85 + +# With visualization color +aluminum = Material(name="Aluminum", density=2.7, color=(0.88, 0.88, 0.88)) +assert aluminum.properties.pbr.base_color[:3] == (0.88, 0.88, 0.88) + +# With formula +lyso = Material(name="LYSO", formula="Lu1.8Y0.2SiO5", density=7.1) +assert lyso.formula == "Lu1.8Y0.2SiO5" ``` ## Using Property Groups - Define multiple properties at once using property group dictionaries: +Define multiple properties at once using property group dictionaries: ```python from pymat import Material - # Define steel with multiple property groups - steel = Material( - name="Stainless Steel 304", - mechanical={"density": 8.0, "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} - ) assert steel.properties.pbr.metallic == 1.0 +# Define steel with multiple property groups +steel = Material( + name="Stainless Steel 304", + mechanical={"density": 8.0, "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}, +) + +assert steel.properties.mechanical.density == 8.0 +assert steel.properties.mechanical.youngs_modulus == 193 +assert steel.properties.thermal.melting_point == 1450 +assert steel.properties.pbr.metallic == 1.0 ``` ## Applying Materials to Shapes - Apply materials to build123d shapes for visualization and mass calculation: +Apply materials to build123d shapes for visualization and mass calculation: ```python from build123d import Box - from pymat import Material - # Create material - steel = Material(name="Steel", density=7.85, color=(0.7, 0.7, 0.7)) +from pymat import Material + +# Create material +steel = Material(name="Steel", density=7.85, color=(0.7, 0.7, 0.7)) + +# Create shape and apply material +box = Box(10, 10, 10) +steel.apply_to(box) + +assert box.material.name == "Steel" +assert box.mass > 0 +assert box.color is not None +``` + +## Computing Molar Mass + +`Material.molar_mass` is a computed property that parses the +chemical formula and looks up each element's atomic weight. +It supports fractional stoichiometry and strips dopant +notation like `LYSO:Ce` so doped-crystal aliases work +unchanged. + +Nothing is stored — it recomputes on each access. That's +intentional: molar mass is definitionally derived from +`formula` and should never drift. Missing or unknown-element +formulas return `None`. See +`docs/decisions/0001-derived-chemistry-properties-live-on-material.md`. + +```python +from pymat import Material + +# Pure element +iron = Material(name="Iron", formula="Fe") +assert iron.molar_mass == 55.85 + +# Simple compound +alumina = Material(name="Alumina", formula="Al2O3") +assert abs(alumina.molar_mass - 101.96) < 0.01 + +# Fractional stoichiometry (a PET-scanner scintillator) +lyso = Material(name="LYSO", formula="Lu1.8Y0.2SiO5") +assert abs(lyso.molar_mass - 440.87) < 0.1 + +# Dopant suffix is stripped +lyso_ce = Material(name="LYSO:Ce", formula="Lu1.8Y0.2SiO5:Ce") +assert lyso_ce.molar_mass == lyso.molar_mass + +# Unit-aware companion accessor (Pint Quantity) +qty = iron.molar_mass_qty +assert qty.to("kg/mol").magnitude == pytest.approx(0.05585, abs=1e-4) + +# Gracefully returns None when no formula is set +unknown = Material(name="Unknown Alloy") +assert unknown.molar_mass is None +``` + +## Low-level: `pymat.elements` + +For callers that don't need a full `Material` object — +e.g. quick stoichiometry calculations inside a Monte Carlo +transport loop — the low-level `pymat.elements` module +exposes the same machinery directly. + +The `ATOMIC_WEIGHT` table is a line-for-line mirror of the +Rust `rs-materials` crate, so Python and Rust Monte Carlo +engines get identical molar masses byte-for-byte. + +```python +from pymat.elements import ( + ATOMIC_WEIGHT, + compute_molar_mass, + parse_formula, +) + +# Atomic weight lookup +assert ATOMIC_WEIGHT["Fe"] == 55.85 +assert ATOMIC_WEIGHT["Lu"] == 175.0 + +# Formula parser: fractional stoichiometry + repeat handling +counts = parse_formula("Lu1.8Y0.2SiO5") +assert counts == {"Lu": 1.8, "Y": 0.2, "Si": 1.0, "O": 5.0} - # Create shape and apply material - box = Box(10, 10, 10) - steel.apply_to(box) assert box.color is not None +# Molar mass directly from a formula string +assert abs(compute_molar_mass("Al2O3") - 101.96) < 0.01 ``` ## Chainable Material Hierarchy - Build hierarchies with grades, tempers, and treatments: +Build hierarchies with grades, tempers, and treatments: ```python from pymat import Material - # Create base stainless steel - stainless = Material( - name="Stainless Steel", - density=8.0, - thermal={"melting_point": 1450} - ) - - # Add grade - s304 = stainless.grade_("304", name="SS 304", mechanical={"yield_strength": 170}) - # Add treatment - passivated = s304.treatment_("passivated", name="SS 304 Passivated") assert passivated.density == 8.0 # Inherited through chain +# Create base stainless steel +stainless = Material(name="Stainless Steel", density=8.0, thermal={"melting_point": 1450}) + +# Add grade +s304 = stainless.grade_("304", name="SS 304", mechanical={"yield_strength": 170}) +assert s304.density == 8.0 # Inherited +assert s304.properties.mechanical.yield_strength == 170 + +# Add treatment +passivated = s304.treatment_("passivated", name="SS 304 Passivated") +assert ( + passivated.path == "stainless_steel.304.passivated" +) # name -> lowercase with underscores +assert passivated.density == 8.0 # Inherited through chain ``` ## Direct Material Access - Load materials and access them directly from the library: +Load materials and access them directly from the library: ```python -from pymat import stainless, aluminum, lyso +from pymat import aluminum, lyso, stainless - # Direct access to materials - s316L = stainless.s316L - al6061 = aluminum.a6061 - lyso_crystal = lyso - assert "LYSO" in lyso_crystal.name +# Direct access to materials +s316L = stainless.s316L +assert s316L.grade == "316L" + +al6061 = aluminum.a6061 +assert al6061.density == 2.7 # Inherited from aluminum + +lyso_crystal = lyso +assert "LYSO" in lyso_crystal.name ``` ## Physics Properties vs Visualization - Understand the difference between measured optical properties (physics) - and rendering properties (visualization): +Understand the difference between measured optical properties (physics) +and rendering properties (visualization): ```python from pymat import Material - # Create transparent material - glass = Material( - name="Optical Glass", - color=(0.9, 0.9, 0.9, 0.8), # Visual: 80% opaque white - optical={"transparency": 95, "refractive_index": 1.517}, # Physics: 95% transmission - pbr={"transmission": 0.8} # Rendering: how transparent it looks - ) +# Create transparent material +glass = Material( + name="Optical Glass", + color=(0.9, 0.9, 0.9, 0.8), # Visual: 80% opaque white + optical={"transparency": 95, "refractive_index": 1.517}, # Physics: 95% transmission + pbr={"transmission": 0.8}, # Rendering: how transparent it looks +) + +# Physics properties (measured) +assert glass.properties.optical.transparency == 95 +assert glass.properties.optical.refractive_index == 1.517 - # Physics properties (measured) - # Visualization properties (rendering) assert glass.properties.pbr.transmission == 0.8 +# Visualization properties (rendering) +assert glass.properties.pbr.base_color[3] == 0.8 # Alpha +assert glass.properties.pbr.transmission == 0.8 ``` ## Scintillator-Specific Properties - Define detector crystals with optical physics properties: +Define detector crystals with optical physics properties: ```python from pymat import Material - lyso_crystal = Material( - name="LYSO:Ce Crystal", - density=7.1, - optical={ - "refractive_index": 1.82, - "transparency": 92, - "light_yield": 32000, # photons/MeV - "decay_time": 41, # ns - "emission_peak": 420, # nm - }, - pbr={"base_color": (0.0, 1.0, 1.0, 0.85), "transmission": 0.85} - ) assert lyso_crystal.properties.pbr.transmission == 0.85 +lyso_crystal = Material( + name="LYSO:Ce Crystal", + density=7.1, + optical={ + "refractive_index": 1.82, + "transparency": 92, + "light_yield": 32000, # photons/MeV + "decay_time": 41, # ns + "emission_peak": 420, # nm + }, + pbr={"base_color": (0.0, 1.0, 1.0, 0.85), "transmission": 0.85}, +) + +assert lyso_crystal.properties.optical.light_yield == 32000 +assert lyso_crystal.properties.optical.decay_time == 41 +assert lyso_crystal.properties.pbr.transmission == 0.85 ``` ## Temperature-Dependent Materials - Use factory functions for materials with properties that depend on external parameters: +Use factory functions for materials with properties that depend on external parameters: ```python from pymat.factories import water - # Water at different temperatures - cold_water = water(4) # Max density - room_water = water(20) # Room temperature - hot_water = water(80) # Heated - # Verify realistic values assert 0.95 < hot_water.density < 0.98 +# Water at different temperatures +cold_water = water(4) # Max density +room_water = water(20) # Room temperature +hot_water = water(80) # Heated + +assert cold_water.density > room_water.density +assert room_water.density > hot_water.density + +# Verify realistic values +assert 0.99 < cold_water.density < 1.01 +assert 0.95 < hot_water.density < 0.98 ``` ## Air at Different Conditions - Create air material at specific temperature and pressure: +Create air material at specific temperature and pressure: ```python from pymat.factories import air - sea_level = air(15, 1.0) # 15°C, 1 atm - high_altitude = air(15, 0.5) # 15°C, 0.5 atm (5500m) +sea_level = air(15, 1.0) # 15°C, 1 atm +high_altitude = air(15, 0.5) # 15°C, 0.5 atm (5500m) - assert sea_level.density > high_altitude.density +assert sea_level.density > high_altitude.density ``` ## Saline Solutions - Create solutions with specific concentration and temperature: +Create solutions with specific concentration and temperature: ```python from pymat.factories import saline, water - # Physiological saline at body temperature - phantom = saline(0.9, temperature_c=37) - # Saline is slightly denser than pure water at same temperature - pure_water_37 = water(37) - # Seawater (3.5% NaCl) at 20°C - seawater = saline(3.5, temperature_c=20) - # Higher concentration = higher density - assert seawater.density > phantom.density +# Physiological saline at body temperature +phantom = saline(0.9, temperature_c=37) +# Saline is slightly denser than pure water at same temperature +pure_water_37 = water(37) +assert phantom.density > pure_water_37.density + +# Seawater (3.5% NaCl) at 20°C +seawater = saline(3.5, temperature_c=20) +# Higher concentration = higher density +assert seawater.density > phantom.density ``` ## Loading Metal Materials - Access various metal materials from the metals category: +Access various metal materials from the metals category: ```python -from pymat import stainless, aluminum, copper - - # Stainless steel variants - s304 = stainless.s304 - s316L = stainless.s316L - # Aluminum alloys - al6061 = aluminum.a6061 - al7075 = aluminum.a7075 - # Copper - copper_material = copper - assert copper_material.density == 8.96 +from pymat import aluminum, copper, stainless + +# Stainless steel variants +s304 = stainless.s304 +s316L = stainless.s316L +assert s304.density == s316L.density # Same base density + +# Aluminum alloys +al6061 = aluminum.a6061 +_ = aluminum.a7075 +assert al6061.density == 2.7 + +# Copper +copper_material = copper +assert copper_material.density == 8.96 ``` ## Plastic Materials - Access plastic materials for 3D printing and engineering: +Access plastic materials for 3D printing and engineering: ```python -from pymat import peek, pla, pc, pmma +from pymat import pc, peek, pla, pmma - # Engineering plastics - # 3D printing plastics - # Transparent plastics assert pc.properties.optical.transparency == 89 +# Engineering plastics +assert peek.properties.manufacturing.print_nozzle_temp == 360 + +# 3D printing plastics +assert pla.properties.manufacturing.printable_fdm is True + +# Transparent plastics +assert pmma.properties.optical.transparency == 92 +assert pc.properties.optical.transparency == 89 ``` ## Scintillator Crystals - Access scintillator materials for radiation detectors: +Access scintillator materials for radiation detectors: ```python -from pymat import lyso, bgo, nai +from pymat import bgo, lyso, nai + +# LYSO crystal +assert lyso.properties.optical.light_yield == 32000 +assert lyso.properties.optical.refractive_index == 1.82 - # LYSO crystal - # BGO crystal - # NaI crystal - assert nai.properties.optical.light_yield == 38000 +# BGO crystal +assert bgo.properties.optical.light_yield == 8500 + +# NaI crystal +assert nai.properties.optical.light_yield == 38000 ``` ## Gas Materials - Access gases for simulation and detector design: +Access gases for simulation and detector design: ```python -from pymat import air, nitrogen, argon, helium, xenon +from pymat import air, argon, helium, nitrogen, xenon + +# Common gases at STP +assert 0.0012 < air.density < 0.0013 # g/cm³ +assert nitrogen.density > helium.density # Helium is lightest +assert xenon.density > argon.density # Heavier noble gases - # Common gases at STP - # Detector gases - assert argon.properties.compliance.radiation_resistant == True +# Detector gases +assert argon.properties.compliance.radiation_resistant is True ``` ## Property Inheritance - Child materials inherit properties from parents unless overridden: +Child materials inherit properties from parents unless overridden: ```python from pymat import Material - # Create material hierarchy - root = Material( - name="Base", - density=7.8, - thermal={"melting_point": 1500, "thermal_conductivity": 50} - ) - - grade1 = root.grade_("G1", mechanical={"yield_strength": 400}) - # Override inherited property - grade2 = root.grade_("G2", thermal={"melting_point": 1600}) - assert grade2.properties.thermal.melting_point == 1600 # Overridden +# Create material hierarchy +root = Material( + name="Base", density=7.8, thermal={"melting_point": 1500, "thermal_conductivity": 50} +) + +grade1 = root.grade_("G1", mechanical={"yield_strength": 400}) +assert grade1.density == 7.8 # Inherited +assert grade1.properties.mechanical.yield_strength == 400 # New property +assert grade1.properties.thermal.melting_point == 1500 # Inherited + +# Override inherited property +grade2 = root.grade_("G2", thermal={"melting_point": 1600}) +assert grade2.properties.thermal.melting_point == 1600 # Overridden ``` ## Automatic Mass Calculation - Materials with density automatically calculate shape mass: +Materials with density automatically calculate shape mass: ```python from build123d import Box - from pymat import stainless, aluminum - # 10x10x10 mm³ box = 1000 mm³ = 1 cm³ - steel_box = Box(10, 10, 10) - stainless.apply_to(steel_box) +from pymat import aluminum, stainless + +# 10x10x10 mm³ box = 1000 mm³ = 1 cm³ +steel_box = Box(10, 10, 10) +stainless.apply_to(steel_box) - # Density = 8.0 g/cm³, Volume = 1 cm³ → Mass = 8.0 g - # Aluminum box - al_box = Box(10, 10, 10) - aluminum.apply_to(al_box) +# Density = 8.0 g/cm³, Volume = 1 cm³ → Mass = 8.0 g +assert 7.9 < steel_box.mass < 8.1 - # Density = 2.7 g/cm³ → Mass = 2.7 g - assert 2.6 < al_box.mass < 2.8 +# Aluminum box +al_box = Box(10, 10, 10) +aluminum.apply_to(al_box) + +# Density = 2.7 g/cm³ → Mass = 2.7 g +assert 2.6 < al_box.mass < 2.8 ``` ## Material Visualization - Materials render with appropriate colors for visualization: +Materials render with appropriate colors for visualization: ```python from build123d import Box - from pymat import stainless, aluminum, lyso - # Create shapes - steel_part = Box(10, 10, 10) - al_part = Box(10, 10, 10) - crystal = Box(10, 10, 10) +from pymat import aluminum, lyso, stainless + +# Create shapes +steel_part = Box(10, 10, 10) +al_part = Box(10, 10, 10) +crystal = Box(10, 10, 10) - # Apply materials - stainless.apply_to(steel_part) - aluminum.apply_to(al_part) - lyso.apply_to(crystal) +# Apply materials +stainless.apply_to(steel_part) +aluminum.apply_to(al_part) +lyso.apply_to(crystal) - # Verify colors are set - # Colors should differ assert crystal.color != steel_part.color +# Verify colors are set +assert steel_part.color is not None +assert al_part.color is not None +assert crystal.color is not None + +# Colors should differ +assert steel_part.color != al_part.color +assert crystal.color != steel_part.color ``` ## Material Categories @@ -393,12 +518,28 @@ my_material = materials["my_material"] ### Enrichment from Chemical Formulas -```python -from pymat import enrich_from_periodictable +`enrich_from_periodictable` reads the material's `formula`, populates +the `composition` dict (element → atom count), and sets the density +**only for pure elements** — compound density is not derivable from +`periodictable`'s dataset and requires a crystallographic source like +Materials Project. Molar mass is always available regardless, via the +computed `Material.molar_mass` property (see the Quick Start). -material = Material(name="Aluminum Oxide", formula="Al2O3") -enrich_from_periodictable(material) -print(material.density) # ~3.95 g/cm³ +```python +from pymat import Material, enrich_from_periodictable + +# Pure element — density is set +iron = Material(name="Iron", formula="Fe") +enrich_from_periodictable(iron) +assert iron.density == 7.874 # set from periodictable +assert iron.molar_mass == 55.85 # computed from formula + +# Compound — composition is set, density is not +alumina = Material(name="Alumina", formula="Al2O3") +enrich_from_periodictable(alumina) +assert alumina.composition == {"Al": 2, "O": 3} +assert alumina.molar_mass == 101.96 # computed from formula +assert alumina.density is None # use enrich_from_matproj for compounds ``` ## Optical vs PBR Properties @@ -422,7 +563,18 @@ These can differ intentionally! A material might be physically transparent (95% MIT +## Design Decisions (ADRs) + +Non-trivial architectural decisions live under `docs/decisions/` as +lightweight ADRs. They explain why the code is shaped the way it is +and the conditions under which the decision should be revisited. + +- [ADR-0001 — Derived chemistry properties live on `Material`](docs/decisions/0001-derived-chemistry-properties-live-on-material.md) + (why `molar_mass` is a computed `@property`, not a stored field) + ## Links - **GitHub**: https://github.com/MorePET/mat - **Issues**: https://github.com/MorePET/mat/issues +- **PyPI**: https://pypi.org/project/py-materials/ +- **Rust crate** (`rs-materials`): https://crates.io/crates/rs-materials diff --git a/docs/README_TEMPLATE.md b/docs/README_TEMPLATE.md index def1e16..d0d92d1 100644 --- a/docs/README_TEMPLATE.md +++ b/docs/README_TEMPLATE.md @@ -1,6 +1,6 @@ # pymat -A hierarchical material library for CAD applications with build123d integration. +A hierarchical material library for CAD applications and Monte Carlo particle transport, with build123d integration. ## Features @@ -8,20 +8,28 @@ A hierarchical material library for CAD applications with build123d integration. - **Property Inheritance**: Children inherit parent properties unless overridden - **Lazy Loading**: Categories load on first access - **TOML Data Storage**: Easy-to-edit material definitions +- **Formula Parsing + Molar Mass**: Computed from the chemical formula via `Material.molar_mass`, with fractional stoichiometry (`Lu1.8Y0.2SiO5`) and dopant suffix stripping (`LYSO:Ce`). Atomic weights mirror the Rust `rs-materials` crate for Python ↔ Rust parity. - **build123d Integration**: Apply materials to shapes with automatic mass calculation - **PBR Rendering**: Physically-based rendering properties for visualization -- **periodictable Integration**: Auto-fill density from chemical formulas +- **periodictable Integration**: Auto-fill composition from chemical formulas for compounds; auto-fill density for pure elements - **Factory Functions**: Temperature/pressure-dependent materials (water, air, saline) - **Separation of Concerns**: Optical properties (physics) separate from PBR (visualization) +- **Python 3.10 – 3.13 supported**, core library depends only on `pint` ## Installation ```bash -# With uv (recommended) -uv add git+https://github.com/MorePET/py-mat.git@latest +# From PyPI (recommended) +pip install py-materials +# or: uv add py-materials -# Or specify version -uv add git+https://github.com/MorePET/py-mat.git@v1.0.0 +# From the main branch (development) +pip install git+https://github.com/MorePET/mat.git@main + +# With optional extras +pip install "py-materials[periodictable]" # auto-fill from chemical formulas +pip install "py-materials[build123d]" # build123d Shape integration (Python <= 3.12) +pip install "py-materials[all]" # everything above ``` ## Quick Start @@ -86,12 +94,28 @@ my_material = materials["my_material"] ### Enrichment from Chemical Formulas -```python -from pymat import enrich_from_periodictable +`enrich_from_periodictable` reads the material's `formula`, populates +the `composition` dict (element → atom count), and sets the density +**only for pure elements** — compound density is not derivable from +`periodictable`'s dataset and requires a crystallographic source like +Materials Project. Molar mass is always available regardless, via the +computed `Material.molar_mass` property (see the Quick Start). -material = Material(name="Aluminum Oxide", formula="Al2O3") -enrich_from_periodictable(material) -print(material.density) # ~3.95 g/cm³ +```python +from pymat import Material, enrich_from_periodictable + +# Pure element — density is set +iron = Material(name="Iron", formula="Fe") +enrich_from_periodictable(iron) +assert iron.density == 7.874 # set from periodictable +assert iron.molar_mass == 55.85 # computed from formula + +# Compound — composition is set, density is not +alumina = Material(name="Alumina", formula="Al2O3") +enrich_from_periodictable(alumina) +assert alumina.composition == {"Al": 2, "O": 3} +assert alumina.molar_mass == 101.96 # computed from formula +assert alumina.density is None # use enrich_from_matproj for compounds ``` ## Optical vs PBR Properties @@ -115,7 +139,18 @@ These can differ intentionally! A material might be physically transparent (95% MIT +## Design Decisions (ADRs) + +Non-trivial architectural decisions live under `docs/decisions/` as +lightweight ADRs. They explain why the code is shaped the way it is +and the conditions under which the decision should be revisited. + +- [ADR-0001 — Derived chemistry properties live on `Material`](docs/decisions/0001-derived-chemistry-properties-live-on-material.md) + (why `molar_mass` is a computed `@property`, not a stored field) + ## Links -- **GitHub**: https://github.com/MorePET/py-mat -- **Issues**: https://github.com/MorePET/py-mat/issues +- **GitHub**: https://github.com/MorePET/mat +- **Issues**: https://github.com/MorePET/mat/issues +- **PyPI**: https://pypi.org/project/py-materials/ +- **Rust crate** (`rs-materials`): https://crates.io/crates/rs-materials diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index ed7a42f..06c5aa8 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -13,33 +13,70 @@ """ import re +import textwrap from pathlib import Path from typing import List, Tuple def extract_docstring(source: str, func_name: str) -> str: - """Extract the docstring from a test function.""" - # Find the function definition + """Extract the docstring from a test function, dedented.""" pattern = rf"def {func_name}\(.*?\):\n\s+\"\"\"(.*?)\"\"\"" match = re.search(pattern, source, re.DOTALL) - if match: - return match.group(1).strip() - return "" + if not match: + return "" + raw = match.group(1) + # Drop the leading newline after `"""` so dedent can see a uniform + # indent on all remaining lines, then strip any trailing whitespace. + if raw.startswith("\n"): + raw = raw[1:] + return textwrap.dedent(raw).rstrip() def extract_code_block(source: str, func_name: str) -> str: - """Extract the code block after the docstring from a test function.""" - # Find the function definition + """ + Extract the code block after the docstring from a test function. + + Returns the code dedented to column 0 with assertion / importorskip + scaffolding removed, so it renders as a plain, runnable Python + snippet in the README. + """ pattern = rf"def {func_name}\(.*?\):\n\s+\"\"\".*?\"\"\"\n(.*?)(?=\n def |\nclass |\Z)" match = re.search(pattern, source, re.DOTALL) - if match: - code = match.group(1).strip() - # Remove pytest.importorskip lines - code = re.sub(r"\s*pytest\.importorskip\(.*?\)\n", "", code) - # Remove assert statements - code = re.sub(r"\s*assert .*?\n", "", code) - return code.strip() - return "" + if not match: + return "" + + raw = match.group(1).rstrip() + if not raw.strip(): + return "" + + # Dedent the entire block relative to its own leading indent. Test + # function bodies are indented 8 spaces (4 for the class, 4 for the + # method); `textwrap.dedent` handles arbitrary depths correctly. + dedented = textwrap.dedent(raw) + + # Filter out pytest scaffolding that doesn't belong in a user-facing + # example. Keep comments, blank lines, imports, asserts (they show + # the expected return values), and actual code. + kept: list[str] = [] + for line in dedented.splitlines(): + stripped = line.strip() + if stripped.startswith("pytest.importorskip"): + continue + kept.append(line) + + # Collapse runs of blank lines left behind by filtering. + cleaned: list[str] = [] + prev_blank = False + for line in kept: + if not line.strip(): + if prev_blank: + continue + prev_blank = True + else: + prev_blank = False + cleaned.append(line) + + return "\n".join(cleaned).strip() def parse_test_file(test_file_path: Path) -> List[Tuple[str, str, str]]: diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 2ac2025..7fa7138 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -78,6 +78,78 @@ def test_applying_to_shapes(self): assert box.mass > 0 assert box.color is not None + def test_molar_mass_from_formula(self): + """ + ## Computing Molar Mass + + `Material.molar_mass` is a computed property that parses the + chemical formula and looks up each element's atomic weight. + It supports fractional stoichiometry and strips dopant + notation like `LYSO:Ce` so doped-crystal aliases work + unchanged. + + Nothing is stored — it recomputes on each access. That's + intentional: molar mass is definitionally derived from + `formula` and should never drift. Missing or unknown-element + formulas return `None`. See + `docs/decisions/0001-derived-chemistry-properties-live-on-material.md`. + """ + from pymat import Material + + # Pure element + iron = Material(name="Iron", formula="Fe") + assert iron.molar_mass == 55.85 + + # Simple compound + alumina = Material(name="Alumina", formula="Al2O3") + assert abs(alumina.molar_mass - 101.96) < 0.01 + + # Fractional stoichiometry (a PET-scanner scintillator) + lyso = Material(name="LYSO", formula="Lu1.8Y0.2SiO5") + assert abs(lyso.molar_mass - 440.87) < 0.1 + + # Dopant suffix is stripped + lyso_ce = Material(name="LYSO:Ce", formula="Lu1.8Y0.2SiO5:Ce") + assert lyso_ce.molar_mass == lyso.molar_mass + + # Unit-aware companion accessor (Pint Quantity) + qty = iron.molar_mass_qty + assert qty.to("kg/mol").magnitude == pytest.approx(0.05585, abs=1e-4) + + # Gracefully returns None when no formula is set + unknown = Material(name="Unknown Alloy") + assert unknown.molar_mass is None + + def test_elements_low_level_api(self): + """ + ## Low-level: `pymat.elements` + + For callers that don't need a full `Material` object — + e.g. quick stoichiometry calculations inside a Monte Carlo + transport loop — the low-level `pymat.elements` module + exposes the same machinery directly. + + The `ATOMIC_WEIGHT` table is a line-for-line mirror of the + Rust `rs-materials` crate, so Python and Rust Monte Carlo + engines get identical molar masses byte-for-byte. + """ + from pymat.elements import ( + ATOMIC_WEIGHT, + compute_molar_mass, + parse_formula, + ) + + # Atomic weight lookup + assert ATOMIC_WEIGHT["Fe"] == 55.85 + assert ATOMIC_WEIGHT["Lu"] == 175.0 + + # Formula parser: fractional stoichiometry + repeat handling + counts = parse_formula("Lu1.8Y0.2SiO5") + assert counts == {"Lu": 1.8, "Y": 0.2, "Si": 1.0, "O": 5.0} + + # Molar mass directly from a formula string + assert abs(compute_molar_mass("Al2O3") - 101.96) < 0.01 + class TestHierarchicalMaterials: """Examples for hierarchical material definitions.""" From 6e285870a7f9c9c8477cab2e98f26044212b38f2 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 16 Apr 2026 22:58:06 +0200 Subject: [PATCH 02/32] =?UTF-8?q?feat:=20stub=20pymat.vis=20module=20?= =?UTF-8?q?=E2=80=94=20client,=20model,=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package pymat.vis with: - vis.__init__: public API re-exports (search, fetch, rowmap_entry, get_manifest). Usage: `from pymat import vis; vis.search(...)` - vis._client: stub implementations for the mat-vis fetch layer. Pure Python, stdlib-only. Raises NotImplementedError until mat-vis publishes first release (M4/M5). - vis._model: Vis dataclass (source_id, finishes, textures, resolve()) + ResolvedChannel. Vis.from_toml() parses the [vis] section from material TOML files. - vis.adapters: standalone to_threejs(), to_gltf(), export_mtlx() functions. Take Material, read from both .properties.pbr and .vis namespaces. Stub implementations. API design rationale: Material.vis holds pointers + cached textures. Fetching/search lives in pymat.vis (module-level functions). Adapters are functions, not methods — consumers can write their own without changing pymat. See MorePET/mat#35. --- src/pymat/__init__.py | 3 +- src/pymat/vis/__init__.py | 35 +++++++++ src/pymat/vis/_client.py | 123 ++++++++++++++++++++++++++++++++ src/pymat/vis/_model.py | 145 ++++++++++++++++++++++++++++++++++++++ src/pymat/vis/adapters.py | 79 +++++++++++++++++++++ 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/pymat/vis/__init__.py create mode 100644 src/pymat/vis/_client.py create mode 100644 src/pymat/vis/_model.py create mode 100644 src/pymat/vis/adapters.py diff --git a/src/pymat/__init__.py b/src/pymat/__init__.py index 12d4c4b..dc8b53b 100644 --- a/src/pymat/__init__.py +++ b/src/pymat/__init__.py @@ -40,7 +40,7 @@ from .core import Material # Exports -from . import factories, registry +from . import factories, registry, vis from .core import Material from .enrichers import enrich_all, enrich_from_matproj, enrich_from_periodictable from .loader import load_category, load_toml @@ -76,6 +76,7 @@ "enrich_from_matproj", "enrich_all", "factories", + "vis", ] # ============================================================================ diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py new file mode 100644 index 0000000..3612718 --- /dev/null +++ b/src/pymat/vis/__init__.py @@ -0,0 +1,35 @@ +""" +Visual material data from mat-vis. + +Public API — all functions importable from `pymat.vis` directly: + + from pymat import vis + + # Search the mat-vis index (runs locally against cached JSON index) + results = vis.search(category="metal", roughness=0.3) + + # Fetch textures by mat-vis source ID + textures = vis.fetch("ambientcg", "Metal_Brushed_001", tier="1k") + textures["color"] # raw PNG bytes + + # Raw rowmap entry for DIY consumers (JS shim, curl, etc.) + entry = vis.rowmap_entry("ambientcg", "Metal_Brushed_001", tier="1k") + # → {"color": {"offset": 102400, "length": 51200}, ...} + + # URL discovery + manifest = vis.get_manifest(release_tag="v2026.04.0") + +The fetch layer is independent of Material — usable standalone +for any consumer that just wants textures without physics data. + +Material.vis wires into this module for its lazy texture loading. +""" + +from pymat.vis._client import fetch, get_manifest, rowmap_entry, search + +__all__ = [ + "search", + "fetch", + "rowmap_entry", + "get_manifest", +] diff --git a/src/pymat/vis/_client.py b/src/pymat/vis/_client.py new file mode 100644 index 0000000..3ac2b10 --- /dev/null +++ b/src/pymat/vis/_client.py @@ -0,0 +1,123 @@ +""" +mat-vis client — pure Python, stdlib-only fetch layer. + +Talks to mat-vis GitHub Release assets via HTTP range reads +guided by rowmap JSON files. No pyarrow, no binary deps. +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_DEFAULT_CACHE_DIR = Path( + os.environ.get("MAT_VIS_CACHE_DIR", Path.home() / ".cache" / "mat-vis") +) +_DEFAULT_MANIFEST_URL: str | None = os.environ.get("MAT_VIS_MANIFEST_URL") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def get_manifest( + release_tag: str | None = None, + manifest_url: str | None = None, +) -> dict: + """Fetch the release manifest (URL discovery for all sources × tiers). + + Args: + release_tag: Calver tag, e.g. "v2026.04.0". Uses latest if None. + manifest_url: Override URL for the manifest JSON. + + Returns: + Parsed release-manifest.json dict. + """ + raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + + +def search( + *, + category: str | None = None, + roughness: float | None = None, + metalness: float | None = None, + source: str | None = None, + limit: int = 20, +) -> list[dict[str, Any]]: + """Search the mat-vis index by scalar similarity. + + Runs locally against the cached JSON index (~3100 entries, in memory). + No network needed if the index has been fetched once. + + Args: + category: Filter by category ("metal", "wood", "stone", ...). + roughness: Target roughness — results ranked by distance. + metalness: Target metalness — results ranked by distance. + source: Filter by source ("ambientcg", "polyhaven", ...). + limit: Max results to return. + + Returns: + List of dicts with "id", "source", "category", "roughness", + "metalness", "score" (lower = closer match). Sorted by score. + """ + raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + + +def fetch( + source: str, + material_id: str, + *, + tier: str = "1k", + cache: bool = True, + cache_dir: Path | None = None, +) -> dict[str, bytes]: + """Fetch textures for a material via rowmap + HTTP range read. + + Args: + source: Source name ("ambientcg", "polyhaven", ...). + material_id: Material ID within the source (e.g. "Metal_Brushed_001"). + tier: Resolution tier ("1k", "2k", "4k", "8k"). + cache: Write fetched bytes to local cache. Default True. + cache_dir: Override cache directory. + + Returns: + Dict of channel → PNG bytes, e.g. {"color": b"\\x89PNG...", ...}. + Only channels present for this material are included. + """ + raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + + +def rowmap_entry( + source: str, + material_id: str, + *, + tier: str = "1k", +) -> dict[str, dict[str, int]]: + """Get raw byte-offset info for DIY consumers. + + Returns the rowmap entry for a material — channel → {offset, length}. + Consumer can use this with their own HTTP client (JS fetch, curl, + Rust reqwest, etc.). + + Args: + source: Source name. + material_id: Material ID. + tier: Resolution tier. + + Returns: + Dict of channel → {"offset": int, "length": int}, e.g.: + {"color": {"offset": 102400, "length": 51200}, ...} + """ + raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") diff --git a/src/pymat/vis/_model.py b/src/pymat/vis/_model.py new file mode 100644 index 0000000..b5477e8 --- /dev/null +++ b/src/pymat/vis/_model.py @@ -0,0 +1,145 @@ +""" +Vis model — the visual representation attached to a Material. + +Material.vis returns a Vis instance. It holds: +- source_id: pointer to a mat-vis appearance +- finishes: dict of finish_name → source_id (for TOML-registered materials) +- textures: dict of channel → PNG bytes (lazy-fetched, cached) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class ResolvedChannel: + """Result of resolving a channel across texture + scalar sources.""" + + texture: bytes | None = None # PNG bytes if texture map available + scalar: float | None = None # scalar fallback from properties.pbr + has_texture: bool = False + + +@dataclass +class Vis: + """Visual representation of a material, backed by mat-vis data. + + Always instantiated (never None on Material). Starts with + source_id=None and empty textures for custom materials. + Populated from TOML [vis] section for registered materials. + + Usage: + steel.vis.source_id # "ambientcg/Metal_Brushed_001" + steel.vis.textures["color"] # PNG bytes (lazy-fetched) + steel.vis.finishes # {"brushed": "...", "polished": "..."} + steel.vis.finish = "polished" # switch appearance + """ + + source_id: str | None = None + tier: str = "1k" + finishes: dict[str, str] = field(default_factory=dict) + _finish: str | None = None + _textures: dict[str, bytes] = field(default_factory=dict, repr=False) + _fetched: bool = False + + @property + def finish(self) -> str | None: + """Current finish name, or None if using source_id directly.""" + return self._finish + + @finish.setter + def finish(self, name: str) -> None: + """Switch to a named finish. Clears cached textures.""" + if name not in self.finishes: + available = list(self.finishes.keys()) + raise ValueError( + f"Unknown finish '{name}'. Available: {available}" + ) + self._finish = name + self.source_id = self.finishes[name] + self._textures.clear() + self._fetched = False + + @property + def textures(self) -> dict[str, bytes]: + """Channel → PNG bytes. Lazy-fetched on first access. + + Returns empty dict if source_id is None (no appearance set). + """ + if self.source_id is None: + return {} + + if not self._fetched: + self._fetch() + + return self._textures + + def resolve(self, channel: str, scalar: float | None = None) -> ResolvedChannel: + """Resolve a channel: texture if available, scalar fallback. + + Args: + channel: Channel name ("roughness", "metalness", etc.) + scalar: Scalar fallback value (from properties.pbr). + + Returns: + ResolvedChannel with texture bytes and/or scalar value. + """ + tex = self.textures.get(channel) + return ResolvedChannel( + texture=tex, + scalar=scalar, + has_texture=tex is not None, + ) + + def _fetch(self) -> None: + """Fetch textures via the vis client. Called lazily.""" + if self.source_id is None: + return + + # Parse "source/material_id" format + parts = self.source_id.split("/", 1) + if len(parts) != 2: + raise ValueError( + f"Invalid source_id '{self.source_id}'. " + f"Expected 'source/material_id' format." + ) + source, material_id = parts + + from pymat.vis._client import fetch + + self._textures = fetch(source, material_id, tier=self.tier) + self._fetched = True + + @classmethod + def from_toml(cls, vis_data: dict[str, Any]) -> Vis: + """Construct from a TOML [vis] section. + + Expected TOML structure: + [material.vis] + default = "brushed" + + [material.vis.finishes] + brushed = "ambientcg/Metal_Brushed_001" + polished = "ambientcg/Metal_Polished_002" + """ + finishes = vis_data.get("finishes", {}) + default_finish = vis_data.get("default") + + source_id = None + finish = None + if default_finish and default_finish in finishes: + source_id = finishes[default_finish] + finish = default_finish + elif finishes: + # No default specified — use first finish + finish = next(iter(finishes)) + source_id = finishes[finish] + + return cls( + source_id=source_id, + finishes=finishes, + _finish=finish, + ) diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py new file mode 100644 index 0000000..f43a5ed --- /dev/null +++ b/src/pymat/vis/adapters.py @@ -0,0 +1,79 @@ +""" +Output adapters — standalone functions that format Material data +for specific consumers. + +Each adapter takes a Material and reads from both .properties.pbr +(scalars) and .vis (textures) to produce consumer-specific output. + + from pymat.vis.adapters import to_threejs, to_gltf, export_mtlx + + threejs_dict = to_threejs(material) + gltf_dict = to_gltf(material) + export_mtlx(material, Path("/tmp/steel/")) + +Consumers can write their own adapters — no changes to pymat needed. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pymat.core import _MaterialInternal as Material + + +def to_threejs(material: Material) -> dict[str, Any]: + """Format as a Three.js MeshPhysicalMaterial-compatible dict. + + Reads PBR scalars from material.properties.pbr and texture maps + from material.vis. Textures are base64-encoded data URIs. + + See docs/specs/field-name-mapping.md for the naming translation. + + Returns: + Dict usable as MeshPhysicalMaterial constructor args. + """ + raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") + + +def to_gltf(material: Material) -> dict[str, Any]: + """Format as a glTF pbrMetallicRoughness material dict. + + Note: glTF packs metalness (B) and roughness (G) into a single + metallicRoughnessTexture. This adapter composites the separate + mat-vis channels if both textures exist. + + See docs/specs/field-name-mapping.md for the naming translation. + + Returns: + Dict conforming to the glTF material spec. + """ + raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") + + +def export_mtlx(material: Material, output_dir: Path) -> Path: + """Export as a MaterialX .mtlx file + PNG textures on disk. + + Writes: + output_dir/ + .mtlx — MaterialX XML, texture refs as siblings + _color.png + _normal.png + ... + + Args: + material: Material with vis data. + output_dir: Directory to write into (created if needed). + + Returns: + Path to the written .mtlx file. + """ + raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") + + +def _to_data_uri(png_bytes: bytes) -> str: + """Encode PNG bytes as a base64 data URI.""" + b64 = base64.b64encode(png_bytes).decode("ascii") + return f"data:image/png;base64,{b64}" From e086eb08b440364ab960b9f3fd03d4c0f8ff1ec8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 02:40:33 +0200 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20wire=20Vis=20into=20Material=20?= =?UTF-8?q?=E2=80=94=20.vis=20property=20+=20TOML=20[vis]=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _MaterialInternal gets ._vis field + .vis property (always returns Vis instance, never None — lazy-constructed on first access) - loader.py parses [vis] sections from TOML, populates via Vis.from_toml() - "vis" added to skip-list so it's not treated as a child material - Added [vis] section to stainless in metals.toml as proof-of-concept: default="brushed", finishes={brushed, polished} Usage: stainless.vis.source_id # "ambientcg/Metal032" stainless.vis.finish # "brushed" stainless.vis.finishes # {"brushed": "...", "polished": "..."} stainless.vis.finish = "polished" # switches source_id Custom materials get empty Vis (source_id=None, textures={}). Textures lazy-fetch via vis._client on first access (not yet implemented — raises NotImplementedError until mat-vis publishes). All 109 existing tests pass. --- src/pymat/core.py | 31 +++++++++++++++++++++++++++++++ src/pymat/data/metals.toml | 7 +++++++ src/pymat/loader.py | 8 ++++++++ 3 files changed, 46 insertions(+) diff --git a/src/pymat/core.py b/src/pymat/core.py index 24aaa67..c418fd8 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -114,6 +114,9 @@ class _MaterialInternal: # Properties (all domains) properties: AllProperties = field(default_factory=AllProperties) + # Visual representation (mat-vis textures, lazy-fetched) + _vis: Optional[Any] = 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) @@ -192,6 +195,34 @@ def __post_init__(self): ): self.properties.pbr.transmission = self.properties.optical.transparency / 100.0 + # ========================================================================= + # Visual representation (mat-vis) + # ========================================================================= + + @property + def vis(self): + """Visual representation — textures, finishes, source reference. + + Always returns a Vis instance (never None). Starts with + source_id=None for materials without mat-vis data. Populated + from TOML [vis] section for registered materials. + + Usage: + steel.vis.source_id # "ambientcg/Metal_Brushed_001" + steel.vis.textures["color"] # PNG bytes (lazy-fetched) + steel.vis.finishes # {"brushed": "...", ...} + steel.vis.finish = "polished" # switch appearance + """ + if self._vis is None: + from pymat.vis._model import Vis + + self._vis = Vis() + return self._vis + + @vis.setter + def vis(self, value): + self._vis = value + # ========================================================================= # Chaining API # ========================================================================= diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index 6dd1e2a..f13ed6a 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -29,6 +29,13 @@ metallic = 1.0 roughness = 0.3 transmission = 0.0 +[stainless.vis] +default = "brushed" + +[stainless.vis.finishes] +brushed = "ambientcg/Metal032" +polished = "ambientcg/Metal012" + # Stainless Steel 304 [stainless.s304] name = "Stainless Steel 304" diff --git a/src/pymat/loader.py b/src/pymat/loader.py index 0a93b7b..d7576f5 100644 --- a/src/pymat/loader.py +++ b/src/pymat/loader.py @@ -206,6 +206,13 @@ def _resolve_material_node( _key=key, ) + # Populate vis from [vis] section if present + vis_data = data.get("vis") + if vis_data and isinstance(vis_data, dict): + from pymat.vis._model import Vis + + material._vis = Vis.from_toml(vis_data) + # Register for direct access registry.register(key, material) @@ -230,6 +237,7 @@ def _resolve_material_node( "temper", "treatment", "vendor", + "vis", ): child_material = _resolve_material_node( child_key, From 37ecaa66182b1242505a4a734ef2d050bdf11ff4 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 02:41:49 +0200 Subject: [PATCH 04/32] test: add 19 tests for pymat.vis module Covers: Vis construction (empty, from_toml with/without default), finish switching (source_id update, cache clear, unknown raises), textures access (empty when no source_id, NotImplementedError when source_id set but client unimplemented), ResolvedChannel (texture available, scalar fallback, neither), Material.vis wiring (custom material empty, settable, same instance, TOML populated, child without vis), module-level API stubs. 128 passed, 26 skipped. --- tests/test_vis.py | 219 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/test_vis.py diff --git a/tests/test_vis.py b/tests/test_vis.py new file mode 100644 index 0000000..6db58a9 --- /dev/null +++ b/tests/test_vis.py @@ -0,0 +1,219 @@ +"""Tests for the pymat.vis module — model, wiring, adapters.""" + +from __future__ import annotations + +import pytest + +from pymat.vis._model import ResolvedChannel, Vis + + +# ── Vis construction ────────────────────────────────────────── + + +class TestVisConstruction: + def test_empty_vis(self): + v = Vis() + assert v.source_id is None + assert v.finish is None + assert v.finishes == {} + assert v.textures == {} + assert v.tier == "1k" + + def test_from_toml_with_default(self): + v = Vis.from_toml( + { + "default": "brushed", + "finishes": { + "brushed": "ambientcg/Metal032", + "polished": "ambientcg/Metal012", + }, + } + ) + assert v.source_id == "ambientcg/Metal032" + assert v.finish == "brushed" + assert v.finishes == { + "brushed": "ambientcg/Metal032", + "polished": "ambientcg/Metal012", + } + + def test_from_toml_no_default_uses_first(self): + v = Vis.from_toml( + { + "finishes": { + "matte": "polyhaven/metal_matte", + "satin": "polyhaven/metal_satin", + } + } + ) + assert v.finish == "matte" + assert v.source_id == "polyhaven/metal_matte" + + def test_from_toml_empty(self): + v = Vis.from_toml({}) + assert v.source_id is None + assert v.finishes == {} + + +# ── Finish switching ────────────────────────────────────────── + + +class TestFinishSwitching: + def test_switch_finish(self): + v = Vis.from_toml( + { + "default": "brushed", + "finishes": { + "brushed": "ambientcg/Metal032", + "polished": "ambientcg/Metal012", + }, + } + ) + assert v.source_id == "ambientcg/Metal032" + + v.finish = "polished" + assert v.source_id == "ambientcg/Metal012" + assert v.finish == "polished" + + def test_switch_clears_cache(self): + v = Vis.from_toml( + { + "default": "a", + "finishes": {"a": "src/a", "b": "src/b"}, + } + ) + # Simulate cached textures + v._textures = {"color": b"fake_png"} + v._fetched = True + + v.finish = "b" + assert v._textures == {} + assert v._fetched is False + + def test_switch_unknown_finish_raises(self): + v = Vis.from_toml( + { + "default": "brushed", + "finishes": {"brushed": "ambientcg/Metal032"}, + } + ) + with pytest.raises(ValueError, match="Unknown finish 'mirror'"): + v.finish = "mirror" + + +# ── Textures access ────────────────────────────────────────── + + +class TestTextures: + def test_no_source_id_returns_empty(self): + v = Vis() + assert v.textures == {} + + def test_with_source_id_attempts_fetch(self): + v = Vis(source_id="ambientcg/Metal032") + with pytest.raises(NotImplementedError): + _ = v.textures + + +# ── ResolvedChannel ────────────────────────────────────────── + + +class TestResolvedChannel: + def test_texture_available(self): + v = Vis() + v._textures = {"roughness": b"\x89PNG_roughness"} + v._fetched = True + v.source_id = "test/id" # set to avoid re-fetch + + rc = v.resolve("roughness", scalar=0.3) + assert rc.has_texture is True + assert rc.texture == b"\x89PNG_roughness" + assert rc.scalar == 0.3 + + def test_texture_missing_fallback_to_scalar(self): + v = Vis() + v._textures = {} + v._fetched = True + v.source_id = "test/id" + + rc = v.resolve("metalness", scalar=1.0) + assert rc.has_texture is False + assert rc.texture is None + assert rc.scalar == 1.0 + + def test_no_texture_no_scalar(self): + v = Vis() + # source_id is None → textures returns {} + rc = v.resolve("color") + assert rc.has_texture is False + assert rc.texture is None + assert rc.scalar is None + + +# ── Material.vis wiring ────────────────────────────────────── + + +class TestMaterialVisWiring: + def test_custom_material_gets_empty_vis(self): + from pymat import Material + + m = Material(name="test-alloy", density=5.0) + assert m.vis is not None + assert m.vis.source_id is None + assert m.vis.textures == {} + + def test_vis_is_settable(self): + from pymat import Material + + m = Material(name="test") + m.vis.source_id = "ambientcg/Wood001" + assert m.vis.source_id == "ambientcg/Wood001" + + def test_vis_same_instance_on_repeat_access(self): + from pymat import Material + + m = Material(name="test") + v1 = m.vis + v2 = m.vis + assert v1 is v2 + + def test_toml_material_gets_populated_vis(self): + from pymat import stainless + + assert stainless.vis.source_id is not None + assert stainless.vis.finish == "brushed" + assert "polished" in stainless.vis.finishes + + def test_child_without_vis_gets_empty(self): + from pymat import stainless + + # s304 has no [vis] section — gets empty Vis + s304 = stainless.s304 + assert s304.vis.source_id is None + + +# ── Module-level API ───────────────────────────────────────── + + +class TestVisModuleApi: + def test_import_vis(self): + from pymat import vis + + assert hasattr(vis, "search") + assert hasattr(vis, "fetch") + assert hasattr(vis, "rowmap_entry") + assert hasattr(vis, "get_manifest") + + def test_stubs_raise_not_implemented(self): + from pymat import vis + + with pytest.raises(NotImplementedError): + vis.search(category="metal") + + with pytest.raises(NotImplementedError): + vis.fetch("ambientcg", "Metal032") + + with pytest.raises(NotImplementedError): + vis.rowmap_entry("ambientcg", "Metal032") + + with pytest.raises(NotImplementedError): + vis.get_manifest() From 1a0ae333eb9342a15a199f9d73a6140845ecbdf0 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 03:42:43 +0200 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20implement=20vis.=5Fclient=20?= =?UTF-8?q?=E2=80=94=20real=20HTTP=20range-read=20fetch=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces NotImplementedError stubs with working implementation: - get_manifest(): returns release tag + base URL - search(): loads JSON indexes, filters by category/source, ranks by roughness/metalness distance - fetch(): rowmap lookup → HTTP Range request → PNG bytes + cache - rowmap_entry(): raw offset/length for DIY consumers Pure Python, stdlib-only (urllib.request). ~250 lines. Tested against live mat-vis v0.1.0 release data: vis.fetch("ambientcg", "Wood049") → 2 channels, 6.3 MB, 2.2s cache hit → 0.002s Also updated tests: mock fetch instead of expecting NotImplementedError, added search mock test, rowmap miss test. 130 passed, 26 skipped. --- src/pymat/vis/_client.py | 207 +++++++++++++++++++++++++++++++++++---- tests/test_vis.py | 60 +++++++++--- 2 files changed, 235 insertions(+), 32 deletions(-) diff --git a/src/pymat/vis/_client.py b/src/pymat/vis/_client.py index 3ac2b10..98257a4 100644 --- a/src/pymat/vis/_client.py +++ b/src/pymat/vis/_client.py @@ -10,9 +10,9 @@ import json import logging import os -from dataclasses import dataclass, field from pathlib import Path from typing import Any +from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen log = logging.getLogger(__name__) @@ -21,10 +21,69 @@ # Configuration # --------------------------------------------------------------------------- -_DEFAULT_CACHE_DIR = Path( +_GITHUB_BASE = "https://github.com/MorePET/mat-vis/releases/download" +_DEFAULT_TAG = "v0.1.0" + +_CACHE_DIR = Path( os.environ.get("MAT_VIS_CACHE_DIR", Path.home() / ".cache" / "mat-vis") ) -_DEFAULT_MANIFEST_URL: str | None = os.environ.get("MAT_VIS_MANIFEST_URL") + + +def _cache_dir() -> Path: + """Return the active cache directory, creating it if needed.""" + d = _CACHE_DIR + d.mkdir(parents=True, exist_ok=True) + return d + + +def _asset_url(tag: str, filename: str) -> str: + """Build a GitHub Release asset URL.""" + return f"{_GITHUB_BASE}/{tag}/{filename}" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _fetch_json(url: str, cache_path: Path | None = None) -> Any: + """Fetch a JSON URL, optionally caching to disk.""" + if cache_path and cache_path.exists(): + return json.loads(cache_path.read_text()) + + log.debug("fetching %s", url) + try: + resp = urlopen(url, timeout=30) + data = json.loads(resp.read()) + except (HTTPError, URLError, TimeoutError) as exc: + raise ConnectionError(f"Failed to fetch {url}: {exc}") from exc + + if cache_path: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(data)) + log.debug("cached to %s", cache_path) + + return data + + +def _range_read(url: str, offset: int, length: int) -> bytes: + """HTTP range read — fetch exactly [offset, offset+length) bytes.""" + req = Request(url) + req.add_header("Range", f"bytes={offset}-{offset + length - 1}") + log.debug("range-read %s [%d, +%d]", url, offset, length) + try: + resp = urlopen(req, timeout=60) + data = resp.read() + except (HTTPError, URLError, TimeoutError) as exc: + raise ConnectionError( + f"Range read failed: {url} [{offset}:{offset + length}]: {exc}" + ) from exc + + if len(data) != length: + raise ValueError( + f"Range read returned {len(data)} bytes, expected {length}" + ) + return data # --------------------------------------------------------------------------- @@ -34,18 +93,23 @@ def get_manifest( release_tag: str | None = None, - manifest_url: str | None = None, ) -> dict: - """Fetch the release manifest (URL discovery for all sources × tiers). + """Fetch release metadata (index + rowmap URLs). + + Currently returns a simple dict with the tag and base URL. + Future: fetch release-manifest.json when it ships. Args: - release_tag: Calver tag, e.g. "v2026.04.0". Uses latest if None. - manifest_url: Override URL for the manifest JSON. + release_tag: Calver tag, e.g. "v0.1.0". Uses default if None. Returns: - Parsed release-manifest.json dict. + Dict with release_tag and base_url. """ - raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + tag = release_tag or _DEFAULT_TAG + return { + "release_tag": tag, + "base_url": f"{_GITHUB_BASE}/{tag}/", + } def search( @@ -54,11 +118,12 @@ def search( roughness: float | None = None, metalness: float | None = None, source: str | None = None, + tag: str | None = None, limit: int = 20, ) -> list[dict[str, Any]]: """Search the mat-vis index by scalar similarity. - Runs locally against the cached JSON index (~3100 entries, in memory). + Runs locally against the cached JSON index (~50 entries in v0.1.0). No network needed if the index has been fetched once. Args: @@ -66,13 +131,48 @@ def search( roughness: Target roughness — results ranked by distance. metalness: Target metalness — results ranked by distance. source: Filter by source ("ambientcg", "polyhaven", ...). + tag: Release tag. Uses default if None. limit: Max results to return. Returns: List of dicts with "id", "source", "category", "roughness", - "metalness", "score" (lower = closer match). Sorted by score. + "metalness", "score". Sorted by score (lower = closer match). """ - raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + tag = tag or _DEFAULT_TAG + cache = _cache_dir() / ".index" + cache.mkdir(parents=True, exist_ok=True) + + # Load all available indexes + entries: list[dict] = [] + for src in ["ambientcg", "polyhaven", "gpuopen", "physicallybased"]: + url = _asset_url(tag, f"{src}.json") + cache_path = cache / f"{src}-{tag}.json" + try: + data = _fetch_json(url, cache_path) + entries.extend(data) + except ConnectionError: + log.debug("index for %s not available at tag %s", src, tag) + + # Filter + if source: + entries = [e for e in entries if e.get("source") == source] + if category: + entries = [e for e in entries if e.get("category") == category] + + # Score by scalar distance + def _score(entry: dict) -> float: + score = 0.0 + if roughness is not None and entry.get("roughness") is not None: + score += abs(entry["roughness"] - roughness) + if metalness is not None and entry.get("metalness") is not None: + score += abs(entry["metalness"] - metalness) + return score + + for e in entries: + e["score"] = _score(e) + + entries.sort(key=lambda e: e["score"]) + return entries[:limit] def fetch( @@ -80,6 +180,7 @@ def fetch( material_id: str, *, tier: str = "1k", + tag: str | None = None, cache: bool = True, cache_dir: Path | None = None, ) -> dict[str, bytes]: @@ -87,16 +188,64 @@ def fetch( Args: source: Source name ("ambientcg", "polyhaven", ...). - material_id: Material ID within the source (e.g. "Metal_Brushed_001"). + material_id: Material ID within the source (e.g. "Metal032"). tier: Resolution tier ("1k", "2k", "4k", "8k"). + tag: Release tag. Uses default if None. cache: Write fetched bytes to local cache. Default True. cache_dir: Override cache directory. Returns: Dict of channel → PNG bytes, e.g. {"color": b"\\x89PNG...", ...}. - Only channels present for this material are included. """ - raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + tag = tag or _DEFAULT_TAG + cdir = Path(cache_dir) if cache_dir else _cache_dir() + + # Check local cache first + mat_cache = cdir / source / tier / material_id + if cache and mat_cache.exists(): + textures = {} + for png_file in mat_cache.glob("*.png"): + textures[png_file.stem] = png_file.read_bytes() + if textures: + log.debug("cache hit: %s/%s (%d channels)", source, material_id, len(textures)) + return textures + + # Fetch rowmap + rowmap = _get_rowmap(source, tier, tag, cdir) + mat_entry = rowmap.get("materials", {}).get(material_id) + if mat_entry is None: + raise KeyError( + f"Material '{material_id}' not found in {source} {tier} rowmap. " + f"Available: {list(rowmap.get('materials', {}).keys())[:10]}..." + ) + + # Build parquet URL + parquet_file = rowmap.get("parquet_file", f"mat-vis-{source}-{tier}.parquet") + parquet_url = _asset_url(tag, parquet_file) + + # Range-read each channel + textures: dict[str, bytes] = {} + for channel, offsets in mat_entry.items(): + png_bytes = _range_read(parquet_url, offsets["offset"], offsets["length"]) + + # Verify PNG magic + if not png_bytes[:4] == b"\x89PNG": + log.warning( + "%s/%s/%s: expected PNG magic, got %r", + source, material_id, channel, png_bytes[:4], + ) + continue + + textures[channel] = png_bytes + + # Cache to disk + if cache: + channel_path = mat_cache / f"{channel}.png" + channel_path.parent.mkdir(parents=True, exist_ok=True) + channel_path.write_bytes(png_bytes) + + log.debug("fetched %s/%s: %d channels", source, material_id, len(textures)) + return textures def rowmap_entry( @@ -104,20 +253,38 @@ def rowmap_entry( material_id: str, *, tier: str = "1k", + tag: str | None = None, ) -> dict[str, dict[str, int]]: """Get raw byte-offset info for DIY consumers. Returns the rowmap entry for a material — channel → {offset, length}. - Consumer can use this with their own HTTP client (JS fetch, curl, - Rust reqwest, etc.). Args: source: Source name. material_id: Material ID. tier: Resolution tier. + tag: Release tag. Uses default if None. Returns: - Dict of channel → {"offset": int, "length": int}, e.g.: - {"color": {"offset": 102400, "length": 51200}, ...} + Dict of channel → {"offset": int, "length": int}. """ - raise NotImplementedError("mat-vis client not yet implemented — see MorePET/mat#35") + tag = tag or _DEFAULT_TAG + rowmap = _get_rowmap(source, tier, tag, _cache_dir()) + mat_entry = rowmap.get("materials", {}).get(material_id) + if mat_entry is None: + raise KeyError( + f"Material '{material_id}' not found in {source} {tier} rowmap" + ) + return mat_entry + + +# --------------------------------------------------------------------------- +# Internal: rowmap fetch + cache +# --------------------------------------------------------------------------- + + +def _get_rowmap(source: str, tier: str, tag: str, cache_root: Path) -> dict: + """Fetch and cache a rowmap JSON.""" + cache_path = cache_root / ".index" / f"{source}-{tier}-{tag}-rowmap.json" + url = _asset_url(tag, f"{source}-{tier}-rowmap.json") + return _fetch_json(url, cache_path) diff --git a/tests/test_vis.py b/tests/test_vis.py index 6db58a9..ea8eb44 100644 --- a/tests/test_vis.py +++ b/tests/test_vis.py @@ -108,10 +108,22 @@ def test_no_source_id_returns_empty(self): v = Vis() assert v.textures == {} - def test_with_source_id_attempts_fetch(self): + def test_with_source_id_attempts_fetch(self, monkeypatch): + """Verify that accessing textures triggers the fetch layer.""" + called = {} + + def mock_fetch(source, material_id, *, tier="1k", **kw): + called["source"] = source + called["material_id"] = material_id + return {"color": b"\x89PNG_mock"} + + monkeypatch.setattr("pymat.vis._client.fetch", mock_fetch) + v = Vis(source_id="ambientcg/Metal032") - with pytest.raises(NotImplementedError): - _ = v.textures + textures = v.textures + assert called["source"] == "ambientcg" + assert called["material_id"] == "Metal032" + assert textures["color"] == b"\x89PNG_mock" # ── ResolvedChannel ────────────────────────────────────────── @@ -203,17 +215,41 @@ def test_import_vis(self): assert hasattr(vis, "rowmap_entry") assert hasattr(vis, "get_manifest") - def test_stubs_raise_not_implemented(self): + def test_get_manifest_returns_dict(self): + from pymat import vis + + manifest = vis.get_manifest(release_tag="v0.1.0") + assert "release_tag" in manifest + assert manifest["release_tag"] == "v0.1.0" + + def test_search_with_mock(self, monkeypatch): + """Search against a mock index (no network).""" from pymat import vis + from pymat.vis import _client - with pytest.raises(NotImplementedError): - vis.search(category="metal") + mock_index = [ + {"id": "Metal001", "source": "ambientcg", "category": "metal", "roughness": 0.3, "metalness": 1.0}, + {"id": "Wood001", "source": "ambientcg", "category": "wood", "roughness": 0.6}, + ] + + def mock_fetch_json(url, cache_path=None): + if "ambientcg" in url: + return mock_index + raise ConnectionError("not available") + + monkeypatch.setattr(_client, "_fetch_json", mock_fetch_json) + + results = vis.search(category="metal") + assert len(results) == 1 + assert results[0]["id"] == "Metal001" + + def test_rowmap_entry_missing_material_raises(self, monkeypatch): + from pymat import vis + from pymat.vis import _client - with pytest.raises(NotImplementedError): - vis.fetch("ambientcg", "Metal032") + mock_rowmap = {"version": 1, "materials": {"Existing001": {"color": {"offset": 0, "length": 100}}}} - with pytest.raises(NotImplementedError): - vis.rowmap_entry("ambientcg", "Metal032") + monkeypatch.setattr(_client, "_fetch_json", lambda url, cache_path=None: mock_rowmap) - with pytest.raises(NotImplementedError): - vis.get_manifest() + with pytest.raises(KeyError, match="NotExist"): + vis.rowmap_entry("ambientcg", "NotExist") From 9d0a1654c1e2839b1f41fb3d67bdf41a32d41fb7 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 03:44:47 +0200 Subject: [PATCH 06/32] feat: implement to_threejs, to_gltf, export_mtlx adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three output adapters, all standalone functions taking Material: - to_threejs(material) → MeshPhysicalMaterial dict with base64 data URIs for textures. Maps metallic→metalness, base_color→ color hex int. Includes ior/transmission/clearcoat/emissive when non-default. - to_gltf(material) → glTF pbrMetallicRoughness dict. Includes KHR_materials_transmission extension when transmission > 0. Note: does NOT pack metalness+roughness into single texture (would need Pillow — left for a dedicated exporter). - export_mtlx(material, path) → writes .mtlx XML + PNG files. Standard Surface node graph with image references as siblings. Field name mapping per docs/specs/field-name-mapping.md. 130 passed, 26 skipped. --- src/pymat/vis/adapters.py | 189 ++++++++++++++++++++++++++++++++++---- 1 file changed, 170 insertions(+), 19 deletions(-) diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index f43a5ed..80f7772 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -12,6 +12,16 @@ export_mtlx(material, Path("/tmp/steel/")) Consumers can write their own adapters — no changes to pymat needed. + +Field name mapping (see docs/specs/field-name-mapping.md): + py-mat metallic → Three.js metalness → glTF metallicFactor + py-mat roughness → Three.js roughness → glTF roughnessFactor + py-mat base_color → Three.js color → glTF baseColorFactor + mat-vis "color" → Three.js "map" → glTF baseColorTexture + mat-vis "normal" → Three.js "normalMap" → glTF normalTexture + mat-vis "roughness" → Three.js "roughnessMap" + mat-vis "metalness" → Three.js "metalnessMap" + mat-vis "ao" → Three.js "aoMap" → glTF occlusionTexture """ from __future__ import annotations @@ -19,38 +29,125 @@ import base64 from pathlib import Path from typing import TYPE_CHECKING, Any +from xml.etree.ElementTree import Element, SubElement, ElementTree, indent if TYPE_CHECKING: from pymat.core import _MaterialInternal as Material +def _to_data_uri(png_bytes: bytes) -> str: + """Encode PNG bytes as a base64 data URI.""" + b64 = base64.b64encode(png_bytes).decode("ascii") + return f"data:image/png;base64,{b64}" + + +def _color_to_hex_int(rgba: tuple) -> int: + """Convert RGBA float tuple to Three.js hex int (RGB only).""" + r = int(min(max(rgba[0], 0.0), 1.0) * 255) + g = int(min(max(rgba[1], 0.0), 1.0) * 255) + b = int(min(max(rgba[2], 0.0), 1.0) * 255) + return (r << 16) | (g << 8) | b + + +# Channel → Three.js texture property name +_THREEJS_TEX_MAP = { + "color": "map", + "normal": "normalMap", + "roughness": "roughnessMap", + "metalness": "metalnessMap", + "ao": "aoMap", + "displacement": "displacementMap", + "emission": "emissiveMap", +} + + def to_threejs(material: Material) -> dict[str, Any]: """Format as a Three.js MeshPhysicalMaterial-compatible dict. Reads PBR scalars from material.properties.pbr and texture maps from material.vis. Textures are base64-encoded data URIs. - See docs/specs/field-name-mapping.md for the naming translation. - Returns: Dict usable as MeshPhysicalMaterial constructor args. """ - raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") + pbr = material.properties.pbr + result: dict[str, Any] = { + "type": "MeshPhysicalMaterial", + "color": _color_to_hex_int(pbr.base_color), + "metalness": pbr.metallic, + "roughness": pbr.roughness, + } + + if pbr.ior != 1.5: + result["ior"] = pbr.ior + if pbr.transmission > 0.0: + result["transmission"] = pbr.transmission + if pbr.clearcoat > 0.0: + result["clearcoat"] = pbr.clearcoat + if any(c > 0 for c in pbr.emissive): + result["emissive"] = _color_to_hex_int((*pbr.emissive, 1.0)) + + # Texture maps from vis (if available) + textures = material.vis.textures if material.vis.source_id else {} + for channel, threejs_prop in _THREEJS_TEX_MAP.items(): + if channel in textures: + result[threejs_prop] = _to_data_uri(textures[channel]) + + return result + + +# Channel → glTF texture property name +_GLTF_TEX_MAP = { + "color": "baseColorTexture", + "normal": "normalTexture", + "ao": "occlusionTexture", + "emission": "emissiveTexture", +} def to_gltf(material: Material) -> dict[str, Any]: - """Format as a glTF pbrMetallicRoughness material dict. + """Format as a glTF material dict. Note: glTF packs metalness (B) and roughness (G) into a single - metallicRoughnessTexture. This adapter composites the separate - mat-vis channels if both textures exist. - - See docs/specs/field-name-mapping.md for the naming translation. + metallicRoughnessTexture. This adapter does NOT composite them — + it sets scalar factors and separate textures. A full glTF exporter + would need to pack the channels. Returns: Dict conforming to the glTF material spec. """ - raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") + pbr = material.properties.pbr + pbr_mr: dict[str, Any] = { + "baseColorFactor": list(pbr.base_color), + "metallicFactor": pbr.metallic, + "roughnessFactor": pbr.roughness, + } + + textures = material.vis.textures if material.vis.source_id else {} + + result: dict[str, Any] = { + "name": material.name, + "pbrMetallicRoughness": pbr_mr, + } + + # Texture references (as data URIs) + for channel, gltf_prop in _GLTF_TEX_MAP.items(): + if channel in textures: + result[gltf_prop] = { + "source": _to_data_uri(textures[channel]), + } + + if any(c > 0 for c in pbr.emissive): + result["emissiveFactor"] = list(pbr.emissive) + + if pbr.transmission > 0.0: + result["extensions"] = { + "KHR_materials_transmission": { + "transmissionFactor": pbr.transmission, + } + } + + return result def export_mtlx(material: Material, output_dir: Path) -> Path: @@ -58,9 +155,9 @@ def export_mtlx(material: Material, output_dir: Path) -> Path: Writes: output_dir/ - .mtlx — MaterialX XML, texture refs as siblings - _color.png - _normal.png + .mtlx + _color.png + _normal.png ... Args: @@ -70,10 +167,64 @@ def export_mtlx(material: Material, output_dir: Path) -> Path: Returns: Path to the written .mtlx file. """ - raise NotImplementedError("Adapter not yet implemented — see MorePET/mat#35") - - -def _to_data_uri(png_bytes: bytes) -> str: - """Encode PNG bytes as a base64 data URI.""" - b64 = base64.b64encode(png_bytes).decode("ascii") - return f"data:image/png;base64,{b64}" + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + safe_name = material.name.replace(" ", "_").replace("/", "_") + pbr = material.properties.pbr + textures = material.vis.textures if material.vis.source_id else {} + + # Write PNG files + tex_refs: dict[str, str] = {} + for channel, png_bytes in textures.items(): + filename = f"{safe_name}_{channel}.png" + (output_dir / filename).write_bytes(png_bytes) + tex_refs[channel] = filename + + # Build MaterialX XML + root = Element("materialx", version="1.39") + root.set("xmlns", "http://www.materialx.org/") + + # Comment with provenance + graph = SubElement(root, "nodegraph", name=f"{safe_name}_graph") + + # Image nodes for each texture + for channel, filename in tex_refs.items(): + img = SubElement(graph, "image", name=f"{channel}_tex", type="color3") + inp = SubElement(img, "input", name="file", type="filename") + inp.set("value", filename) + + # Standard surface node + surface = SubElement(graph, "standard_surface", name="surface", type="surfaceshader") + + # Base color + if "color" in tex_refs: + inp = SubElement(surface, "input", name="base_color", type="color3") + inp.set("nodename", "color_tex") + else: + inp = SubElement(surface, "input", name="base_color", type="color3") + inp.set("value", f"{pbr.base_color[0]}, {pbr.base_color[1]}, {pbr.base_color[2]}") + + # Normal + if "normal" in tex_refs: + inp = SubElement(surface, "input", name="normal", type="vector3") + inp.set("nodename", "normal_tex") + + # Roughness + inp = SubElement(surface, "input", name="specular_roughness", type="float") + inp.set("value", str(pbr.roughness)) + + # Metallic + inp = SubElement(surface, "input", name="metalness", type="float") + inp.set("value", str(pbr.metallic)) + + # Material node + mat = SubElement(root, "material", name=f"{safe_name}_mat") + SubElement(mat, "shaderref", name="surface_ref", node="surface") + + # Write + mtlx_path = output_dir / f"{safe_name}.mtlx" + indent(root, space=" ") + tree = ElementTree(root) + tree.write(str(mtlx_path), xml_declaration=True, encoding="utf-8") + + return mtlx_path From 866800ac7d5f00ecf3a3b4cdac54a612c87a3ded Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 03:45:51 +0200 Subject: [PATCH 07/32] =?UTF-8?q?chore:=20simplify=20deps=20=E2=80=94=20pe?= =?UTF-8?q?riodictable=20to=20core,=20remove=20stale=20extras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mat#34 discussion: - periodictable moved from [periodictable] extra to core deps (~800 KB, used for molar mass + element lookups) - Removed extras: [periodictable], [matproj], [build123d], [all] - matproj → curation-time tool only (mat#36) - build123d → reversed dep direction (they depend on us) - uncertainties → will become core in a follow-up (mat#33) - Only [dev] extra remains (pytest, build123d for integration tests) pip install mat → pint + periodictable + tomli. ~2 MB. --- pyproject.toml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c65abc5..3831dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,27 +29,16 @@ classifiers = [ dependencies = [ "pint>=0.20", + "periodictable>=1.6.0", "tomli>=1.0.0; python_version == '3.10'", ] [project.optional-dependencies] -periodictable = ["periodictable>=1.6.0"] -matproj = ["pymatgen>=2024.0.0"] -# build123d pulls cadquery-ocp and vtk, which only publish wheels for -# cp311/cp312 as of 2026-04. The environment marker means `pip install -# 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'"] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "build123d>=0.7.0; python_version<'3.13'", ] -all = [ - "periodictable>=1.6.0", - "pymatgen>=2024.0.0", - "build123d>=0.7.0; python_version<'3.13'", -] [project.urls] Homepage = "https://github.com/MorePet/py-mat" From a7cf766114064f7ca34cb47b2380c064dd900c1a Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 08:44:38 +0200 Subject: [PATCH 08/32] feat: Vis.discover() + enrichment script for auto-proposing vis mappings Vis.discover(category, roughness, metallic): - Searches mat-vis index for matching appearances - Returns ranked candidates, does NOT auto-set source_id - Pass auto_set=True to pick top match - Uses vis.search() under the hood scripts/enrich_vis.py: - Runs vis.search() against all unmapped TOML materials - Outputs proposed [vis] sections as TOML - Designed to run in CI via enrich-vis.yml workflow - Opens PR for human review of proposed mappings - Tested against live mat-vis v0.1.0: 120 proposals generated 3 new tests for discover (candidates, auto_set, empty results). 133 passed, 26 skipped. --- scripts/enrich_vis.py | 146 ++++++++++++++++++++++++++++++++++++++++ src/pymat/vis/_model.py | 54 +++++++++++++++ tests/test_vis.py | 42 ++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 scripts/enrich_vis.py diff --git a/scripts/enrich_vis.py b/scripts/enrich_vis.py new file mode 100644 index 0000000..48c8cc4 --- /dev/null +++ b/scripts/enrich_vis.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Propose [vis] mappings for materials that don't have one. + +Queries the mat-vis index via pymat.vis.search() and suggests +best-match appearances for each TOML-registered material. + +Usage: + # Preview proposed mappings + python scripts/enrich_vis.py + + # Write proposed TOML patches to a file + python scripts/enrich_vis.py --output proposed_vis.toml + + # Auto-apply top matches (use with care — review the PR) + python scripts/enrich_vis.py --apply + +Called by .github/workflows/enrich-vis.yml on each mat-vis release. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Ensure src/ is importable when running from repo root +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from pymat import load_all, vis + + +def _category_hint(material) -> str | None: + """Infer a mat-vis category from the material's data.""" + # Try the material's TOML key path for hints + key = getattr(material, "_key", "") or "" + name = material.name.lower() + + hints = { + "metal": ["steel", "aluminum", "copper", "brass", "titanium", "tungsten", "lead", "iron"], + "wood": ["wood", "plywood", "mdf", "balsa"], + "plastic": ["peek", "delrin", "nylon", "pla", "abs", "petg", "ptfe", "pmma"], + "ceramic": ["alumina", "macor", "zirconia"], + "glass": ["glass"], + "concrete": ["concrete"], + "stone": ["stone", "rock", "marble", "granite"], + } + + for category, keywords in hints.items(): + if any(kw in name or kw in key for kw in keywords): + return category + return None + + +def propose_mappings(limit_per_material: int = 3) -> list[dict]: + """Generate vis mapping proposals for unmapped materials.""" + materials = load_all() + proposals = [] + + for key, mat in materials.items(): + # Skip if already has vis mapping + if mat.vis.source_id is not None: + continue + + category = _category_hint(mat) + pbr = mat.properties.pbr + + try: + candidates = vis.search( + category=category, + roughness=pbr.roughness if pbr.roughness != 0.5 else None, + metalness=pbr.metallic if pbr.metallic != 0.0 else None, + limit=limit_per_material, + ) + except ConnectionError: + continue + + if not candidates: + continue + + # Format source_id as "source/id" + for c in candidates: + if "source" in c and "id" in c and "/" not in c["id"]: + c["id"] = f"{c['source']}/{c['id']}" + + proposals.append({ + "material_key": key, + "material_name": mat.name, + "category_hint": category, + "pbr_roughness": pbr.roughness, + "pbr_metallic": pbr.metallic, + "candidates": candidates, + }) + + return proposals + + +def format_toml(proposals: list[dict]) -> str: + """Format proposals as TOML [vis] sections.""" + lines = ["# Auto-generated vis mapping proposals", "# Review before merging", ""] + + for p in proposals: + key = p["material_key"] + top = p["candidates"][0] + alts = p["candidates"][1:] + + lines.append(f"# {p['material_name']} (category: {p['category_hint']})") + lines.append(f"# roughness={p['pbr_roughness']}, metallic={p['pbr_metallic']}") + if alts: + lines.append(f"# alternatives: {[c['id'] for c in alts]}") + lines.append(f'[{key}.vis]') + lines.append(f'default = "auto"') + lines.append(f'') + lines.append(f'[{key}.vis.finishes]') + lines.append(f'auto = "{top["id"]}"') + lines.append("") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", "-o", help="Write proposals to file") + parser.add_argument("--apply", action="store_true", help="Apply top matches (not yet implemented)") + args = parser.parse_args() + + proposals = propose_mappings() + + if not proposals: + print("No unmapped materials found (or mat-vis index unavailable)") + return + + toml_text = format_toml(proposals) + + if args.output: + Path(args.output).write_text(toml_text) + print(f"Wrote {len(proposals)} proposals to {args.output}") + else: + print(toml_text) + print(f"\n# {len(proposals)} materials proposed") + + if args.apply: + print("--apply not yet implemented. Review the proposals and add to TOML manually.") + + +if __name__ == "__main__": + main() diff --git a/src/pymat/vis/_model.py b/src/pymat/vis/_model.py index b5477e8..f9b30bf 100644 --- a/src/pymat/vis/_model.py +++ b/src/pymat/vis/_model.py @@ -94,6 +94,60 @@ def resolve(self, channel: str, scalar: float | None = None) -> ResolvedChannel: has_texture=tex is not None, ) + def discover( + self, + *, + category: str | None = None, + roughness: float | None = None, + metallic: float | None = None, + limit: int = 5, + auto_set: bool = False, + ) -> list[dict[str, Any]]: + """Search mat-vis for appearances matching this material's scalars. + + Does NOT set source_id automatically — returns candidates for + the user to review. Pass auto_set=True to pick the top match. + + Args: + category: Filter by category. If None, tries to infer from + the material's existing PBR properties. + roughness: Target roughness. If None, reads from the material. + metallic: Target metalness. If None, reads from the material. + limit: Max candidates to return. + auto_set: If True, set source_id to the top match. + + Returns: + List of candidate dicts with "id", "source", "category", + "score". Sorted by score (lower = closer match). + + Example: + candidates = steel.vis.discover(category="metal") + # [{"id": "ambientcg/Metal032", "score": 0.05}, ...] + steel.vis.source_id = candidates[0]["id"] # manual pick + # or: + steel.vis.discover(category="metal", auto_set=True) + """ + from pymat.vis._client import search + + results = search( + category=category, + roughness=roughness, + metalness=metallic, + limit=limit, + ) + + # Reformat ids as "source/id" for direct assignment + for r in results: + if "source" in r and "id" in r and "/" not in r["id"]: + r["id"] = f"{r['source']}/{r['id']}" + + if auto_set and results: + self.source_id = results[0]["id"] + self._textures.clear() + self._fetched = False + + return results + def _fetch(self) -> None: """Fetch textures via the vis client. Called lazily.""" if self.source_id is None: diff --git a/tests/test_vis.py b/tests/test_vis.py index ea8eb44..cbfbc95 100644 --- a/tests/test_vis.py +++ b/tests/test_vis.py @@ -161,6 +161,48 @@ def test_no_texture_no_scalar(self): assert rc.scalar is None +# ── Discover ───────────────────────────────────────────────── + + +class TestDiscover: + def test_discover_returns_candidates(self, monkeypatch): + from pymat.vis import _client + + mock_results = [ + {"id": "Metal032", "source": "ambientcg", "category": "metal", "score": 0.1}, + {"id": "Metal012", "source": "ambientcg", "category": "metal", "score": 0.3}, + ] + monkeypatch.setattr(_client, "search", lambda **kw: mock_results) + + v = Vis() + candidates = v.discover(category="metal") + assert len(candidates) == 2 + assert candidates[0]["id"] == "ambientcg/Metal032" + assert v.source_id is None # not set without auto_set + + def test_discover_auto_set(self, monkeypatch): + from pymat.vis import _client + + mock_results = [ + {"id": "Metal032", "source": "ambientcg", "category": "metal", "score": 0.1}, + ] + monkeypatch.setattr(_client, "search", lambda **kw: mock_results) + + v = Vis() + v.discover(category="metal", auto_set=True) + assert v.source_id == "ambientcg/Metal032" + + def test_discover_no_results(self, monkeypatch): + from pymat.vis import _client + + monkeypatch.setattr(_client, "search", lambda **kw: []) + + v = Vis() + candidates = v.discover(category="exotic") + assert candidates == [] + assert v.source_id is None + + # ── Material.vis wiring ────────────────────────────────────── From 0b050d4339aee8596e0241a916dc13c55b72c155 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 08:51:19 +0200 Subject: [PATCH 09/32] =?UTF-8?q?feat:=20add=20vis.prefetch()=20=E2=80=94?= =?UTF-8?q?=20bulk=20download=20for=20CI=20and=20air-gapped=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads all materials for a (source × tier) into the local cache. Skips already-cached materials, logs progress every 10 materials. Usage: from pymat import vis vis.prefetch("ambientcg", tier="1k") # downloads all ~50 materials # subsequent vis.fetch() calls are instant from cache Closes the last ADR-0004 compliance gap. 133 passed, 26 skipped. --- src/pymat/vis/__init__.py | 3 ++- src/pymat/vis/_client.py | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index 3612718..1ce1f48 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -25,11 +25,12 @@ Material.vis wires into this module for its lazy texture loading. """ -from pymat.vis._client import fetch, get_manifest, rowmap_entry, search +from pymat.vis._client import fetch, get_manifest, prefetch, rowmap_entry, search __all__ = [ "search", "fetch", + "prefetch", "rowmap_entry", "get_manifest", ] diff --git a/src/pymat/vis/_client.py b/src/pymat/vis/_client.py index 98257a4..0ed73d4 100644 --- a/src/pymat/vis/_client.py +++ b/src/pymat/vis/_client.py @@ -248,6 +248,53 @@ def fetch( return textures +def prefetch( + source: str, + *, + tier: str = "1k", + tag: str | None = None, + cache_dir: Path | None = None, +) -> int: + """Download all materials for a source × tier into the local cache. + + Use for CI pipelines, air-gapped environments, or any scenario + where you want zero network traffic after setup. + + Args: + source: Source name ("ambientcg", "polyhaven", ...). + tier: Resolution tier ("1k", "2k", "4k", "8k"). + tag: Release tag. Uses default if None. + cache_dir: Override cache directory. + + Returns: + Number of materials prefetched. + """ + tag = tag or _DEFAULT_TAG + cdir = Path(cache_dir) if cache_dir else _cache_dir() + rowmap = _get_rowmap(source, tier, tag, cdir) + materials = rowmap.get("materials", {}) + + fetched = 0 + total = len(materials) + for i, material_id in enumerate(materials, 1): + # Skip if already cached + mat_cache = cdir / source / tier / material_id + if mat_cache.exists() and any(mat_cache.glob("*.png")): + fetched += 1 + continue + + try: + fetch(source, material_id, tier=tier, tag=tag, cache_dir=cdir) + fetched += 1 + if i % 10 == 0 or i == total: + log.info("prefetch %s/%s: %d/%d", source, tier, i, total) + except (ConnectionError, ValueError, KeyError): + log.warning("prefetch: failed to fetch %s/%s", source, material_id) + + log.info("prefetch complete: %s %s — %d/%d cached", source, tier, fetched, total) + return fetched + + def rowmap_entry( source: str, material_id: str, From ceaabb92da8feca54102a8d736eeed3ce856191f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 11:17:36 +0200 Subject: [PATCH 10/32] refactor: vendor mat-vis reference client, thin wrappers only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration per mat#37: - Vendored mat_vis_client.py + adapters.py from mat-vis v2026.04.0 release assets into _vendor_client.py and _vendor_adapters.py - _client.py reduced to thin wrapper (~110 lines) delegating to vendored MatVisClient class - adapters.py reduced to thin wrappers (~75 lines) that extract scalars + textures from Material and pass to vendored generic adapters (scalars dict, textures dict — no Material dependency) - Index seeding workaround: downloads index JSONs from release assets since they're not in git yet (mat-vis#40) - fetch() gracefully returns {} on errors instead of crashing - Tests updated to mock vendored client, not internal functions Blocked on full functionality by mat-vis#40 (missing ambientcg index + partitioned rowmap handling in vendored client). 133 passed, 26 skipped. --- src/pymat/vis/_client.py | 375 +++++++------------------- src/pymat/vis/_vendor_adapters.py | 280 +++++++++++++++++++ src/pymat/vis/_vendor_client.py | 431 ++++++++++++++++++++++++++++++ src/pymat/vis/adapters.py | 237 +++------------- tests/test_vis.py | 33 ++- 5 files changed, 877 insertions(+), 479 deletions(-) create mode 100644 src/pymat/vis/_vendor_adapters.py create mode 100644 src/pymat/vis/_vendor_client.py diff --git a/src/pymat/vis/_client.py b/src/pymat/vis/_client.py index 0ed73d4..15cc1d6 100644 --- a/src/pymat/vis/_client.py +++ b/src/pymat/vis/_client.py @@ -1,115 +1,87 @@ """ -mat-vis client — pure Python, stdlib-only fetch layer. +mat-vis client — thin wrapper around the vendored mat-vis reference client. -Talks to mat-vis GitHub Release assets via HTTP range reads -guided by rowmap JSON files. No pyarrow, no binary deps. +The actual fetch logic lives in _vendor_client.py (shipped by mat-vis +as a release asset, vendored here). This module adapts it to pymat's +API surface (module-level functions instead of class methods). + +See MorePET/mat#37 for migration context. """ from __future__ import annotations -import json import logging -import os from pathlib import Path from typing import Any -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -log = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -_GITHUB_BASE = "https://github.com/MorePET/mat-vis/releases/download" -_DEFAULT_TAG = "v0.1.0" - -_CACHE_DIR = Path( - os.environ.get("MAT_VIS_CACHE_DIR", Path.home() / ".cache" / "mat-vis") -) - - -def _cache_dir() -> Path: - """Return the active cache directory, creating it if needed.""" - d = _CACHE_DIR - d.mkdir(parents=True, exist_ok=True) - return d +from pymat.vis._vendor_client import MatVisClient -def _asset_url(tag: str, filename: str) -> str: - """Build a GitHub Release asset URL.""" - return f"{_GITHUB_BASE}/{tag}/{filename}" - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -def _fetch_json(url: str, cache_path: Path | None = None) -> Any: - """Fetch a JSON URL, optionally caching to disk.""" - if cache_path and cache_path.exists(): - return json.loads(cache_path.read_text()) - - log.debug("fetching %s", url) - try: - resp = urlopen(url, timeout=30) - data = json.loads(resp.read()) - except (HTTPError, URLError, TimeoutError) as exc: - raise ConnectionError(f"Failed to fetch {url}: {exc}") from exc - - if cache_path: - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(json.dumps(data)) - log.debug("cached to %s", cache_path) +log = logging.getLogger(__name__) - return data +# Singleton client instance — lazy-initialized +_client: MatVisClient | None = None -def _range_read(url: str, offset: int, length: int) -> bytes: - """HTTP range read — fetch exactly [offset, offset+length) bytes.""" - req = Request(url) - req.add_header("Range", f"bytes={offset}-{offset + length - 1}") - log.debug("range-read %s [%d, +%d]", url, offset, length) - try: - resp = urlopen(req, timeout=60) - data = resp.read() - except (HTTPError, URLError, TimeoutError) as exc: - raise ConnectionError( - f"Range read failed: {url} [{offset}:{offset + length}]: {exc}" - ) from exc +def _get_client() -> MatVisClient: + global _client + if _client is None: + _client = MatVisClient() + # Pre-cache indexes from release assets since they're not in git yet. + # The vendored client tries raw.githubusercontent.com which 404s. + # This workaround seeds the cache so the client finds them locally. + _seed_indexes(_client) + return _client - if len(data) != length: - raise ValueError( - f"Range read returned {len(data)} bytes, expected {length}" - ) - return data +def _seed_indexes(client: MatVisClient) -> None: + """Download index JSONs from release assets into the client's cache. -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- + Tries the current release tag first, then falls back to older tags. + Workaround for indexes not being in git yet — they ship as release assets. + """ + import urllib.request + from urllib.error import HTTPError, URLError + + manifest = client.manifest + tag = manifest.get("release_tag", "") + cache_dir = client._cache_dir / ".indexes" + cache_dir.mkdir(parents=True, exist_ok=True) + + # Collect all sources from manifest + known defaults + sources = set() + for tier_data in manifest.get("tiers", {}).values(): + sources.update(tier_data.get("sources", {}).keys()) + sources.add("physicallybased") + + # Tags to try in order (current release, then older) + tags_to_try = [tag, "v0.1.0"] if tag != "v0.1.0" else [tag] + base = "https://github.com/MorePET/mat-vis/releases/download" + + for source in sources: + cache_path = cache_dir / f"{source}.json" + if cache_path.exists(): + continue + for t in tags_to_try: + url = f"{base}/{t}/{source}.json" + try: + req = urllib.request.Request(url, headers={"User-Agent": "pymat"}) + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read() + cache_path.write_bytes(data) + log.debug("seeded index: %s (from %s)", source, t) + break + except (HTTPError, URLError): + continue + else: + log.debug("index not available for %s", source) def get_manifest( release_tag: str | None = None, ) -> dict: - """Fetch release metadata (index + rowmap URLs). - - Currently returns a simple dict with the tag and base URL. - Future: fetch release-manifest.json when it ships. - - Args: - release_tag: Calver tag, e.g. "v0.1.0". Uses default if None. - - Returns: - Dict with release_tag and base_url. - """ - tag = release_tag or _DEFAULT_TAG - return { - "release_tag": tag, - "base_url": f"{_GITHUB_BASE}/{tag}/", - } + """Fetch release manifest (URL discovery for all sources × tiers).""" + client = MatVisClient(tag=release_tag) if release_tag else _get_client() + return client.manifest def search( @@ -121,58 +93,35 @@ def search( tag: str | None = None, limit: int = 20, ) -> list[dict[str, Any]]: - """Search the mat-vis index by scalar similarity. - - Runs locally against the cached JSON index (~50 entries in v0.1.0). - No network needed if the index has been fetched once. - - Args: - category: Filter by category ("metal", "wood", "stone", ...). - roughness: Target roughness — results ranked by distance. - metalness: Target metalness — results ranked by distance. - source: Filter by source ("ambientcg", "polyhaven", ...). - tag: Release tag. Uses default if None. - limit: Max results to return. - - Returns: - List of dicts with "id", "source", "category", "roughness", - "metalness", "score". Sorted by score (lower = closer match). - """ - tag = tag or _DEFAULT_TAG - cache = _cache_dir() / ".index" - cache.mkdir(parents=True, exist_ok=True) - - # Load all available indexes - entries: list[dict] = [] - for src in ["ambientcg", "polyhaven", "gpuopen", "physicallybased"]: - url = _asset_url(tag, f"{src}.json") - cache_path = cache / f"{src}-{tag}.json" - try: - data = _fetch_json(url, cache_path) - entries.extend(data) - except ConnectionError: - log.debug("index for %s not available at tag %s", src, tag) - - # Filter - if source: - entries = [e for e in entries if e.get("source") == source] - if category: - entries = [e for e in entries if e.get("category") == category] - - # Score by scalar distance - def _score(entry: dict) -> float: + """Search the mat-vis index by category and scalar similarity.""" + client = MatVisClient(tag=tag) if tag else _get_client() + + roughness_range = None + if roughness is not None: + roughness_range = (max(0.0, roughness - 0.2), min(1.0, roughness + 0.2)) + + metalness_range = None + if metalness is not None: + metalness_range = (max(0.0, metalness - 0.2), min(1.0, metalness + 0.2)) + + results = client.search( + category=category, + source=source, + roughness_range=roughness_range, + metalness_range=metalness_range, + ) + + # Add score for compatibility with existing callers + for r in results: score = 0.0 - if roughness is not None and entry.get("roughness") is not None: - score += abs(entry["roughness"] - roughness) - if metalness is not None and entry.get("metalness") is not None: - score += abs(entry["metalness"] - metalness) - return score + if roughness is not None and r.get("roughness") is not None: + score += abs(r["roughness"] - roughness) + if metalness is not None and r.get("metalness") is not None: + score += abs(r["metalness"] - metalness) + r["score"] = score - for e in entries: - e["score"] = _score(e) - - entries.sort(key=lambda e: e["score"]) - return entries[:limit] + results.sort(key=lambda r: r["score"]) + return results[:limit] def fetch( @@ -184,68 +133,13 @@ def fetch( cache: bool = True, cache_dir: Path | None = None, ) -> dict[str, bytes]: - """Fetch textures for a material via rowmap + HTTP range read. - - Args: - source: Source name ("ambientcg", "polyhaven", ...). - material_id: Material ID within the source (e.g. "Metal032"). - tier: Resolution tier ("1k", "2k", "4k", "8k"). - tag: Release tag. Uses default if None. - cache: Write fetched bytes to local cache. Default True. - cache_dir: Override cache directory. - - Returns: - Dict of channel → PNG bytes, e.g. {"color": b"\\x89PNG...", ...}. - """ - tag = tag or _DEFAULT_TAG - cdir = Path(cache_dir) if cache_dir else _cache_dir() - - # Check local cache first - mat_cache = cdir / source / tier / material_id - if cache and mat_cache.exists(): - textures = {} - for png_file in mat_cache.glob("*.png"): - textures[png_file.stem] = png_file.read_bytes() - if textures: - log.debug("cache hit: %s/%s (%d channels)", source, material_id, len(textures)) - return textures - - # Fetch rowmap - rowmap = _get_rowmap(source, tier, tag, cdir) - mat_entry = rowmap.get("materials", {}).get(material_id) - if mat_entry is None: - raise KeyError( - f"Material '{material_id}' not found in {source} {tier} rowmap. " - f"Available: {list(rowmap.get('materials', {}).keys())[:10]}..." - ) - - # Build parquet URL - parquet_file = rowmap.get("parquet_file", f"mat-vis-{source}-{tier}.parquet") - parquet_url = _asset_url(tag, parquet_file) - - # Range-read each channel - textures: dict[str, bytes] = {} - for channel, offsets in mat_entry.items(): - png_bytes = _range_read(parquet_url, offsets["offset"], offsets["length"]) - - # Verify PNG magic - if not png_bytes[:4] == b"\x89PNG": - log.warning( - "%s/%s/%s: expected PNG magic, got %r", - source, material_id, channel, png_bytes[:4], - ) - continue - - textures[channel] = png_bytes - - # Cache to disk - if cache: - channel_path = mat_cache / f"{channel}.png" - channel_path.parent.mkdir(parents=True, exist_ok=True) - channel_path.write_bytes(png_bytes) - - log.debug("fetched %s/%s: %d channels", source, material_id, len(textures)) - return textures + """Fetch textures for a material via rowmap + HTTP range read.""" + client = MatVisClient(tag=tag) if tag else _get_client() + try: + return client.fetch_all_textures(source, material_id, tier=tier) + except Exception as exc: + log.warning("vis.fetch(%s/%s): %s", source, material_id, exc) + return {} def prefetch( @@ -255,44 +149,9 @@ def prefetch( tag: str | None = None, cache_dir: Path | None = None, ) -> int: - """Download all materials for a source × tier into the local cache. - - Use for CI pipelines, air-gapped environments, or any scenario - where you want zero network traffic after setup. - - Args: - source: Source name ("ambientcg", "polyhaven", ...). - tier: Resolution tier ("1k", "2k", "4k", "8k"). - tag: Release tag. Uses default if None. - cache_dir: Override cache directory. - - Returns: - Number of materials prefetched. - """ - tag = tag or _DEFAULT_TAG - cdir = Path(cache_dir) if cache_dir else _cache_dir() - rowmap = _get_rowmap(source, tier, tag, cdir) - materials = rowmap.get("materials", {}) - - fetched = 0 - total = len(materials) - for i, material_id in enumerate(materials, 1): - # Skip if already cached - mat_cache = cdir / source / tier / material_id - if mat_cache.exists() and any(mat_cache.glob("*.png")): - fetched += 1 - continue - - try: - fetch(source, material_id, tier=tier, tag=tag, cache_dir=cdir) - fetched += 1 - if i % 10 == 0 or i == total: - log.info("prefetch %s/%s: %d/%d", source, tier, i, total) - except (ConnectionError, ValueError, KeyError): - log.warning("prefetch: failed to fetch %s/%s", source, material_id) - - log.info("prefetch complete: %s %s — %d/%d cached", source, tier, fetched, total) - return fetched + """Download all materials for a source × tier into the local cache.""" + client = MatVisClient(tag=tag) if tag else _get_client() + return client.prefetch(source, tier=tier) def rowmap_entry( @@ -302,36 +161,6 @@ def rowmap_entry( tier: str = "1k", tag: str | None = None, ) -> dict[str, dict[str, int]]: - """Get raw byte-offset info for DIY consumers. - - Returns the rowmap entry for a material — channel → {offset, length}. - - Args: - source: Source name. - material_id: Material ID. - tier: Resolution tier. - tag: Release tag. Uses default if None. - - Returns: - Dict of channel → {"offset": int, "length": int}. - """ - tag = tag or _DEFAULT_TAG - rowmap = _get_rowmap(source, tier, tag, _cache_dir()) - mat_entry = rowmap.get("materials", {}).get(material_id) - if mat_entry is None: - raise KeyError( - f"Material '{material_id}' not found in {source} {tier} rowmap" - ) - return mat_entry - - -# --------------------------------------------------------------------------- -# Internal: rowmap fetch + cache -# --------------------------------------------------------------------------- - - -def _get_rowmap(source: str, tier: str, tag: str, cache_root: Path) -> dict: - """Fetch and cache a rowmap JSON.""" - cache_path = cache_root / ".index" / f"{source}-{tier}-{tag}-rowmap.json" - url = _asset_url(tag, f"{source}-{tier}-rowmap.json") - return _fetch_json(url, cache_path) + """Get raw byte-offset info for DIY consumers.""" + client = MatVisClient(tag=tag) if tag else _get_client() + return client.rowmap_entry(source, material_id, tier=tier) diff --git a/src/pymat/vis/_vendor_adapters.py b/src/pymat/vis/_vendor_adapters.py new file mode 100644 index 0000000..e591161 --- /dev/null +++ b/src/pymat/vis/_vendor_adapters.py @@ -0,0 +1,280 @@ +"""mat-vis output format adapters — Three.js, glTF, MaterialX. + +Converts generic scalars + texture bytes into renderer-specific formats. +Pure Python, zero dependencies (uses only stdlib xml.etree for MaterialX). + +All functions take generic dicts — no Material class dependency: + + from adapters import to_threejs, to_gltf, export_mtlx + result = to_threejs(scalars, textures) + +Field name mapping follows docs/specs/field-name-mapping.md. +""" + +from __future__ import annotations + +import base64 +import xml.etree.ElementTree as ET +from pathlib import Path + +# ── Field name mapping tables ─────────────────────────────────── +# +# mat-vis channel name -> renderer property name +# Canonical source: docs/specs/field-name-mapping.md + +_THREEJS_TEX_MAP: dict[str, str] = { + "color": "map", + "normal": "normalMap", + "roughness": "roughnessMap", + "metalness": "metalnessMap", + "ao": "aoMap", + "displacement": "displacementMap", + "emission": "emissiveMap", +} + +_GLTF_TEX_MAP: dict[str, str] = { + "color": "baseColorTexture", + "normal": "normalTexture", + "ao": "occlusionTexture", + "emission": "emissiveTexture", + # roughness + metalness are packed into metallicRoughnessTexture + # handled separately in to_gltf() +} + +_MTLX_TEX_MAP: dict[str, str] = { + "color": "base_color", + "normal": "normal", + "roughness": "specular_roughness", + "metalness": "metalness", + "ao": "occlusion", + "displacement": "displacement", + "emission": "emission_color", +} + + +# ── Helpers ───────────────────────────────────────────────────── + + +def _to_data_uri(png_bytes: bytes) -> str: + """Encode PNG bytes as a base64 data URI.""" + b64 = base64.b64encode(png_bytes).decode("ascii") + return f"data:image/png;base64,{b64}" + + +def _color_hex_to_int(hex_str: str) -> int: + """Convert '#RRGGBB' hex string to an integer (Three.js color format). + + >>> _color_hex_to_int('#A0522D') + 10506797 + """ + return int(hex_str.lstrip("#"), 16) + + +def _color_hex_to_rgba(hex_str: str) -> list[float]: + """Convert '#RRGGBB' to glTF [R, G, B, A] floats in [0, 1].""" + h = hex_str.lstrip("#") + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return [r / 255.0, g / 255.0, b / 255.0, 1.0] + + +# ── Three.js adapter ─────────────────────────────────────────── + + +def to_threejs( + scalars: dict, + textures: dict[str, bytes] | None = None, +) -> dict: + """Convert to a Three.js MeshPhysicalMaterial parameter dict. + + Args: + scalars: Material scalars. Expected keys (all optional): + - metalness (float 0-1) + - roughness (float 0-1) + - color_hex (str '#RRGGBB') + - ior (float) + - transmission (float 0-1) + textures: Channel name -> PNG bytes. Keys are mat-vis channel + names: color, normal, roughness, metalness, ao, + displacement, emission. + + Returns: + Dict suitable for `new THREE.MeshPhysicalMaterial(result)`. + Textures are embedded as base64 data URIs. + """ + textures = textures or {} + result: dict = {"type": "MeshPhysicalMaterial"} + + # Scalars + if "metalness" in scalars and scalars["metalness"] is not None: + result["metalness"] = scalars["metalness"] + if "roughness" in scalars and scalars["roughness"] is not None: + result["roughness"] = scalars["roughness"] + if "color_hex" in scalars and scalars["color_hex"] is not None: + result["color"] = _color_hex_to_int(scalars["color_hex"]) + if "ior" in scalars and scalars["ior"] is not None: + result["ior"] = scalars["ior"] + if "transmission" in scalars and scalars["transmission"] is not None: + result["transmission"] = scalars["transmission"] + + # Textures as data URIs + for channel, prop in _THREEJS_TEX_MAP.items(): + if channel in textures: + result[prop] = _to_data_uri(textures[channel]) + + return result + + +# ── glTF adapter ──────────────────────────────────────────────── + + +def to_gltf( + scalars: dict, + textures: dict[str, bytes] | None = None, +) -> dict: + """Convert to a glTF pbrMetallicRoughness material dict. + + Args: + scalars: Same as to_threejs(). + textures: Same as to_threejs(). + + Returns: + Dict conforming to glTF 2.0 material schema. Textures are + embedded as base64 data URIs in the 'uri' field. Does NOT + pack metalness+roughness into a single texture (that requires + image compositing which needs PIL or similar). Instead, scalar + factors are used when separate maps are provided. + + Note: + Full glTF compliance for metallicRoughnessTexture packing + requires image processing (PIL/Pillow). This adapter provides + a best-effort output using scalar factors and separate texture + references. For production glTF export, consider using a + library like pygltflib. + """ + textures = textures or {} + pbr: dict = {} + material: dict = {"pbrMetallicRoughness": pbr} + + # Scalar factors + if "metalness" in scalars and scalars["metalness"] is not None: + pbr["metallicFactor"] = scalars["metalness"] + if "roughness" in scalars and scalars["roughness"] is not None: + pbr["roughnessFactor"] = scalars["roughness"] + if "color_hex" in scalars and scalars["color_hex"] is not None: + pbr["baseColorFactor"] = _color_hex_to_rgba(scalars["color_hex"]) + + # IOR extension + if "ior" in scalars and scalars["ior"] is not None: + material.setdefault("extensions", {})["KHR_materials_ior"] = {"ior": scalars["ior"]} + + # Transmission extension + if "transmission" in scalars and scalars["transmission"] is not None: + material.setdefault("extensions", {})["KHR_materials_transmission"] = { + "transmissionFactor": scalars["transmission"] + } + + # Textures + def _tex_ref(png_bytes: bytes) -> dict: + return {"source": {"uri": _to_data_uri(png_bytes)}} + + for channel, prop in _GLTF_TEX_MAP.items(): + if channel in textures: + if prop in ("normalTexture", "occlusionTexture", "emissiveTexture"): + material[prop] = _tex_ref(textures[channel]) + else: + pbr[prop] = _tex_ref(textures[channel]) + + # metallicRoughnessTexture: only if BOTH metalness and roughness + # textures are available (proper packing needs image processing, + # so we note this limitation) + if "metalness" in textures and "roughness" in textures: + pbr["_note_metallicRoughnessTexture"] = ( + "Separate metalness and roughness textures provided. " + "Pack into a single metallicRoughnessTexture (B=metal, G=rough) " + "for full glTF compliance." + ) + + return material + + +# ── MaterialX adapter ────────────────────────────────────────── + + +def export_mtlx( + scalars: dict, + textures: dict[str, bytes] | None = None, + output_dir: str | Path = ".", + *, + material_name: str = "Material", +) -> Path: + """Export as MaterialX .mtlx XML with referenced PNG files. + + Args: + scalars: Same as to_threejs(). + textures: Same as to_threejs(). PNGs are written to output_dir. + output_dir: Directory for .mtlx and .png files. + material_name: Name for the material in the .mtlx document. + + Returns: + Path to the written .mtlx file. + """ + textures = textures or {} + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + # Root element + root = ET.Element("materialx", version="1.38") + + # Standard surface node + sr = ET.SubElement(root, "standard_surface", name=f"SR_{material_name}", type="surfaceshader") + + # Scalar inputs + if "metalness" in scalars and scalars["metalness"] is not None: + ET.SubElement(sr, "input", name="metalness", type="float", value=str(scalars["metalness"])) + if "roughness" in scalars and scalars["roughness"] is not None: + ET.SubElement( + sr, "input", name="specular_roughness", type="float", value=str(scalars["roughness"]) + ) + if "color_hex" in scalars and scalars["color_hex"] is not None: + rgba = _color_hex_to_rgba(scalars["color_hex"]) + color_str = f"{rgba[0]:.4f}, {rgba[1]:.4f}, {rgba[2]:.4f}" + ET.SubElement(sr, "input", name="base_color", type="color3", value=color_str) + if "ior" in scalars and scalars["ior"] is not None: + ET.SubElement(sr, "input", name="specular_IOR", type="float", value=str(scalars["ior"])) + if "transmission" in scalars and scalars["transmission"] is not None: + ET.SubElement( + sr, "input", name="transmission", type="float", value=str(scalars["transmission"]) + ) + + # Write texture PNGs and create image nodes + for channel, png_bytes in textures.items(): + mtlx_input = _MTLX_TEX_MAP.get(channel) + if mtlx_input is None: + continue + + # Write PNG file + png_filename = f"{material_name}_{channel}.png" + png_path = out / png_filename + png_path.write_bytes(png_bytes) + + # Image node + img_node_name = f"IMG_{material_name}_{channel}" + img = ET.SubElement(root, "tiledimage", name=img_node_name, type="color3") + ET.SubElement(img, "input", name="file", type="filename", value=png_filename) + + # Connect texture to shader input + ET.SubElement(sr, "input", name=mtlx_input, type="color3", nodename=img_node_name) + + # Surface material + mat = ET.SubElement(root, "surfacematerial", name=material_name, type="material") + ET.SubElement( + mat, "input", name="surfaceshader", type="surfaceshader", nodename=f"SR_{material_name}" + ) + + # Write .mtlx file + tree = ET.ElementTree(root) + ET.indent(tree, space=" ") + mtlx_path = out / f"{material_name}.mtlx" + tree.write(mtlx_path, encoding="unicode", xml_declaration=True) + + return mtlx_path diff --git a/src/pymat/vis/_vendor_client.py b/src/pymat/vis/_vendor_client.py new file mode 100644 index 0000000..102edaf --- /dev/null +++ b/src/pymat/vis/_vendor_client.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""mat-vis reference client — pure Python, zero dependencies. + +Fetches PBR textures from mat-vis GitHub Releases via HTTP range reads. +Uses only urllib (stdlib). No pyarrow, no binary deps. + +Usage as library: + from mat_vis_client import MatVisClient + client = MatVisClient() + png_bytes = client.fetch_texture("ambientcg", "Rock064", "color", tier="1k") + + # Search by category and scalar ranges + results = client.search("metal", roughness_range=(0.2, 0.6)) + + # Bulk prefetch all materials for offline use + client.prefetch("ambientcg", tier="1k") + +Usage as CLI: + python mat_vis_client.py list # list sources × tiers + python mat_vis_client.py materials ambientcg 1k # list materials + python mat_vis_client.py fetch ambientcg Rock064 color 1k # fetch PNG → stdout + python mat_vis_client.py fetch ambientcg Rock064 color 1k -o rock.png + python mat_vis_client.py search metal --roughness 0.2:0.6 # search materials + python mat_vis_client.py prefetch ambientcg 1k # bulk download +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request +from pathlib import Path + +REPO = "MorePET/mat-vis" +GITHUB_RELEASES = f"https://github.com/{REPO}/releases" +GITHUB_RAW = f"https://raw.githubusercontent.com/{REPO}" +LATEST_MANIFEST_URL = f"{GITHUB_RELEASES}/latest/download/release-manifest.json" +DEFAULT_CACHE_DIR = Path(os.environ.get("MAT_VIS_CACHE", Path.home() / ".cache" / "mat-vis")) +USER_AGENT = "mat-vis-client/0.2 (Python)" + +# Valid categories per index-schema.json +CATEGORIES = frozenset( + [ + "metal", + "wood", + "stone", + "fabric", + "plastic", + "concrete", + "ceramic", + "glass", + "organic", + "other", + ] +) + + +def _get(url: str, headers: dict | None = None) -> bytes: + """HTTP GET with User-Agent.""" + hdrs = {"User-Agent": USER_AGENT} + if headers: + hdrs.update(headers) + req = urllib.request.Request(url, headers=hdrs) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read() + + +def _get_json(url: str) -> dict | list: + """Fetch and parse JSON.""" + return json.loads(_get(url)) + + +def _in_range(value: float | None, lo: float, hi: float) -> bool: + """Check if a value falls within [lo, hi]. None values never match.""" + if value is None: + return False + return lo <= value <= hi + + +class MatVisClient: + """Lightweight client for mat-vis texture data.""" + + def __init__( + self, + *, + manifest_url: str | None = None, + cache_dir: Path | None = None, + tag: str | None = None, + ): + self._cache_dir = cache_dir or DEFAULT_CACHE_DIR + self._manifest: dict | None = None + self._rowmaps: dict[str, dict] = {} + self._indexes: dict[str, list[dict]] = {} + self._tag = tag + + if manifest_url: + self._manifest_url = manifest_url + elif tag: + self._manifest_url = f"{GITHUB_RELEASES}/download/{tag}/release-manifest.json" + else: + self._manifest_url = LATEST_MANIFEST_URL + + @property + def manifest(self) -> dict: + """Fetch and cache the release manifest.""" + if self._manifest is None: + cache_path = self._cache_dir / ".manifest.json" + if cache_path.exists(): + self._manifest = json.loads(cache_path.read_text()) + else: + self._manifest = _get_json(self._manifest_url) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(self._manifest, indent=2)) + return self._manifest + + def sources(self, tier: str = "1k") -> list[str]: + """List available sources for a tier.""" + tier_data = self.manifest.get("tiers", {}).get(tier, {}) + return list(tier_data.get("sources", {}).keys()) + + def tiers(self) -> list[str]: + """List available tiers.""" + return list(self.manifest.get("tiers", {}).keys()) + + def rowmap(self, source: str, tier: str, category: str | None = None) -> dict: + """Fetch and cache a rowmap.""" + key = f"{source}-{tier}-{category or 'all'}" + if key not in self._rowmaps: + tier_data = self.manifest["tiers"][tier] + base_url = tier_data["base_url"] + src_data = tier_data["sources"][source] + + # Find matching rowmap file + rowmap_files = src_data.get("rowmap_files", []) + if not rowmap_files: + # Legacy single rowmap + rowmap_file = src_data.get("rowmap_file", f"{source}-{tier}-rowmap.json") + rowmap_files = [rowmap_file] + + if category: + matches = [f for f in rowmap_files if category in f] + rowmap_file = matches[0] if matches else rowmap_files[0] + else: + rowmap_file = rowmap_files[0] + + cache_path = self._cache_dir / ".rowmaps" / rowmap_file + if cache_path.exists(): + self._rowmaps[key] = json.loads(cache_path.read_text()) + else: + url = base_url + rowmap_file + self._rowmaps[key] = _get_json(url) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(self._rowmaps[key], indent=2)) + + return self._rowmaps[key] + + def materials(self, source: str, tier: str) -> list[str]: + """List material IDs available for a source × tier.""" + rm = self.rowmap(source, tier) + return sorted(rm.get("materials", {}).keys()) + + def channels(self, source: str, material_id: str, tier: str) -> list[str]: + """List channels available for a material.""" + rm = self.rowmap(source, tier) + mat = rm.get("materials", {}).get(material_id, {}) + return sorted(mat.keys()) + + # ── Index & search ────────────────────────────────────────── + + def _index_url(self, source: str) -> str: + """Build the URL for a source's index JSON.""" + ref = self._tag or "main" + return f"{GITHUB_RAW}/{ref}/index/{source}.json" + + def index(self, source: str) -> list[dict]: + """Fetch and cache the material index for a source. + + Returns a list of material entries per index-schema.json. + """ + if source not in self._indexes: + cache_path = self._cache_dir / ".indexes" / f"{source}.json" + if cache_path.exists(): + self._indexes[source] = json.loads(cache_path.read_text()) + else: + url = self._index_url(source) + self._indexes[source] = _get_json(url) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(self._indexes[source], indent=2)) + return self._indexes[source] + + def search( + self, + category: str | None = None, + *, + roughness_range: tuple[float, float] | None = None, + metalness_range: tuple[float, float] | None = None, + source: str | None = None, + tier: str = "1k", + ) -> list[dict]: + """Search materials by category and scalar ranges. + + Fetches index JSON for the given source (or all sources for the + tier) and filters locally. Returns matching index entries. + + Args: + category: Filter by material category (e.g. "metal", "wood"). + roughness_range: (min, max) roughness filter, inclusive. + metalness_range: (min, max) metalness filter, inclusive. + source: Limit search to one source. If None, searches all + sources available for the given tier. + tier: Only return materials that have this tier available. + """ + if category and category not in CATEGORIES: + raise ValueError( + f"Unknown category {category!r}. Valid: {', '.join(sorted(CATEGORIES))}" + ) + + sources = [source] if source else self.sources(tier) + results: list[dict] = [] + + for src in sources: + for entry in self.index(src): + if category and entry.get("category") != category: + continue + if roughness_range and not _in_range(entry.get("roughness"), *roughness_range): + continue + if metalness_range and not _in_range(entry.get("metalness"), *metalness_range): + continue + if tier not in entry.get("available_tiers", []): + continue + results.append(entry) + + return results + + # ── Bulk operations ───────────────────────────────────────── + + def fetch_all_textures( + self, + source: str, + material_id: str, + tier: str = "1k", + ) -> dict[str, bytes]: + """Fetch all texture channels for a material. + + Returns a dict mapping channel name to PNG bytes. + """ + chs = self.channels(source, material_id, tier) + return {ch: self.fetch_texture(source, material_id, ch, tier) for ch in chs} + + def prefetch( + self, + source: str, + tier: str = "1k", + *, + on_progress: callable | None = None, + ) -> int: + """Bulk download all materials for a source + tier to cache. + + Args: + source: Source name (e.g. "ambientcg"). + tier: Resolution tier (default "1k"). + on_progress: Optional callback(material_id, index, total). + + Returns the number of materials fetched. + """ + mat_ids = self.materials(source, tier) + total = len(mat_ids) + + for i, mid in enumerate(mat_ids): + self.fetch_all_textures(source, mid, tier) + if on_progress: + on_progress(mid, i + 1, total) + + return total + + def rowmap_entry( + self, + source: str, + material_id: str, + tier: str = "1k", + ) -> dict[str, dict]: + """Get raw rowmap offsets for a material (for DIY consumers). + + Returns a dict of channel -> {offset, length, parquet_file}. + """ + rm = self.rowmap(source, tier) + mat = rm["materials"][material_id] + parquet_file = rm["parquet_file"] + return { + ch: {"offset": info["offset"], "length": info["length"], "parquet_file": parquet_file} + for ch, info in mat.items() + } + + def fetch_texture( + self, + source: str, + material_id: str, + channel: str, + tier: str = "1k", + ) -> bytes: + """Fetch a single texture PNG via HTTP range read. + + Returns raw PNG bytes. Caches locally. + """ + # Check cache first + cache_path = self._cache_dir / source / tier / material_id / f"{channel}.png" + if cache_path.exists(): + return cache_path.read_bytes() + + # Find in rowmap + rm = self.rowmap(source, tier) + mat = rm["materials"][material_id] + rng = mat[channel] + offset = rng["offset"] + length = rng["length"] + + # Find parquet URL + tier_data = self.manifest["tiers"][tier] + base_url = tier_data["base_url"] + parquet_file = rm["parquet_file"] + url = base_url + parquet_file + + # HTTP range read + range_header = f"bytes={offset}-{offset + length - 1}" + data = _get(url, headers={"Range": range_header}) + + # Verify PNG + if data[:4] != b"\x89PNG": + raise ValueError(f"Expected PNG, got {data[:4]!r}") + + # Cache + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(data) + + return data + + +# ── CLI ───────────────────────────────────────────────────────── + + +def _parse_range(s: str) -> tuple[float, float]: + """Parse 'lo:hi' into a (lo, hi) tuple.""" + parts = s.split(":") + if len(parts) != 2: + raise ValueError(f"Expected lo:hi, got {s!r}") + return float(parts[0]), float(parts[1]) + + +def main(): + import argparse + + parser = argparse.ArgumentParser(prog="mat-vis-client", description="mat-vis texture client") + parser.add_argument("--tag", help="Release tag (default: latest)") + sub = parser.add_subparsers(dest="cmd", required=True) + + sub.add_parser("list", help="List sources x tiers") + + p_mat = sub.add_parser("materials", help="List materials for a source x tier") + p_mat.add_argument("source") + p_mat.add_argument("tier", nargs="?", default="1k") + + p_fetch = sub.add_parser("fetch", help="Fetch a texture PNG") + p_fetch.add_argument("source") + p_fetch.add_argument("material") + p_fetch.add_argument("channel") + p_fetch.add_argument("tier", nargs="?", default="1k") + p_fetch.add_argument("-o", "--output", help="Output file (default: stdout)") + + p_search = sub.add_parser("search", help="Search materials by category / scalars") + p_search.add_argument("category", nargs="?", help="Category filter (e.g. metal, wood)") + p_search.add_argument("--source", help="Limit to one source") + p_search.add_argument("--tier", default="1k") + p_search.add_argument("--roughness", help="Roughness range as lo:hi") + p_search.add_argument("--metalness", help="Metalness range as lo:hi") + + p_prefetch = sub.add_parser("prefetch", help="Bulk download all materials for source x tier") + p_prefetch.add_argument("source") + p_prefetch.add_argument("tier", nargs="?", default="1k") + + args = parser.parse_args() + client = MatVisClient(tag=args.tag) + + if args.cmd == "list": + for tier in client.tiers(): + sources = client.sources(tier) + print(f"{tier}: {', '.join(sources)}") + + elif args.cmd == "materials": + for mid in client.materials(args.source, args.tier): + print(mid) + + elif args.cmd == "fetch": + data = client.fetch_texture(args.source, args.material, args.channel, args.tier) + if args.output: + Path(args.output).write_bytes(data) + print(f"Wrote {args.output} ({len(data):,} bytes)", file=sys.stderr) + else: + sys.stdout.buffer.write(data) + + elif args.cmd == "search": + roughness = _parse_range(args.roughness) if args.roughness else None + metalness = _parse_range(args.metalness) if args.metalness else None + results = client.search( + args.category, + roughness_range=roughness, + metalness_range=metalness, + source=args.source, + tier=args.tier, + ) + for entry in results: + scalars = [] + if entry.get("roughness") is not None: + scalars.append(f"R={entry['roughness']:.2f}") + if entry.get("metalness") is not None: + scalars.append(f"M={entry['metalness']:.2f}") + scalar_str = f" ({', '.join(scalars)})" if scalars else "" + print(f"{entry['source']}/{entry['id']} [{entry.get('category', '?')}]{scalar_str}") + print(f"\n{len(results)} result(s)", file=sys.stderr) + + elif args.cmd == "prefetch": + + def _progress(mid: str, i: int, total: int) -> None: + print(f"[{i}/{total}] {mid}", file=sys.stderr) + + n = client.prefetch(args.source, args.tier, on_progress=_progress) + print(f"Prefetched {n} materials", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index 80f7772..535917c 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -1,230 +1,81 @@ """ -Output adapters — standalone functions that format Material data -for specific consumers. +Output adapters — thin wrappers that map Material to mat-vis's +generic adapter functions. -Each adapter takes a Material and reads from both .properties.pbr -(scalars) and .vis (textures) to produce consumer-specific output. +The actual format logic (Three.js field names, glTF schema, +MaterialX XML) lives in _vendor_adapters.py (shipped by mat-vis). +These wrappers extract scalars + textures from a Material and +pass them through. from pymat.vis.adapters import to_threejs, to_gltf, export_mtlx - - threejs_dict = to_threejs(material) - gltf_dict = to_gltf(material) - export_mtlx(material, Path("/tmp/steel/")) - -Consumers can write their own adapters — no changes to pymat needed. - -Field name mapping (see docs/specs/field-name-mapping.md): - py-mat metallic → Three.js metalness → glTF metallicFactor - py-mat roughness → Three.js roughness → glTF roughnessFactor - py-mat base_color → Three.js color → glTF baseColorFactor - mat-vis "color" → Three.js "map" → glTF baseColorTexture - mat-vis "normal" → Three.js "normalMap" → glTF normalTexture - mat-vis "roughness" → Three.js "roughnessMap" - mat-vis "metalness" → Three.js "metalnessMap" - mat-vis "ao" → Three.js "aoMap" → glTF occlusionTexture + result = to_threejs(material) """ from __future__ import annotations -import base64 from pathlib import Path from typing import TYPE_CHECKING, Any -from xml.etree.ElementTree import Element, SubElement, ElementTree, indent + +from pymat.vis._vendor_adapters import export_mtlx as _export_mtlx +from pymat.vis._vendor_adapters import to_gltf as _to_gltf +from pymat.vis._vendor_adapters import to_threejs as _to_threejs if TYPE_CHECKING: from pymat.core import _MaterialInternal as Material -def _to_data_uri(png_bytes: bytes) -> str: - """Encode PNG bytes as a base64 data URI.""" - b64 = base64.b64encode(png_bytes).decode("ascii") - return f"data:image/png;base64,{b64}" - - -def _color_to_hex_int(rgba: tuple) -> int: - """Convert RGBA float tuple to Three.js hex int (RGB only).""" - r = int(min(max(rgba[0], 0.0), 1.0) * 255) - g = int(min(max(rgba[1], 0.0), 1.0) * 255) - b = int(min(max(rgba[2], 0.0), 1.0) * 255) - return (r << 16) | (g << 8) | b +def _extract_scalars(material: Material) -> dict[str, Any]: + """Extract PBR scalars from Material.properties.pbr as a plain dict.""" + pbr = material.properties.pbr + scalars: dict[str, Any] = { + "metallic": pbr.metallic, + "roughness": pbr.roughness, + "base_color": pbr.base_color, + "ior": pbr.ior, + "transmission": pbr.transmission, + "clearcoat": pbr.clearcoat, + "emissive": pbr.emissive, + } + return scalars -# Channel → Three.js texture property name -_THREEJS_TEX_MAP = { - "color": "map", - "normal": "normalMap", - "roughness": "roughnessMap", - "metalness": "metalnessMap", - "ao": "aoMap", - "displacement": "displacementMap", - "emission": "emissiveMap", -} +def _extract_textures(material: Material) -> dict[str, bytes]: + """Extract texture bytes from Material.vis (if available).""" + if material.vis.source_id is None: + return {} + return material.vis.textures def to_threejs(material: Material) -> dict[str, Any]: """Format as a Three.js MeshPhysicalMaterial-compatible dict. Reads PBR scalars from material.properties.pbr and texture maps - from material.vis. Textures are base64-encoded data URIs. - - Returns: - Dict usable as MeshPhysicalMaterial constructor args. + from material.vis. Delegates to mat-vis's generic adapter. """ - pbr = material.properties.pbr - result: dict[str, Any] = { - "type": "MeshPhysicalMaterial", - "color": _color_to_hex_int(pbr.base_color), - "metalness": pbr.metallic, - "roughness": pbr.roughness, - } - - if pbr.ior != 1.5: - result["ior"] = pbr.ior - if pbr.transmission > 0.0: - result["transmission"] = pbr.transmission - if pbr.clearcoat > 0.0: - result["clearcoat"] = pbr.clearcoat - if any(c > 0 for c in pbr.emissive): - result["emissive"] = _color_to_hex_int((*pbr.emissive, 1.0)) - - # Texture maps from vis (if available) - textures = material.vis.textures if material.vis.source_id else {} - for channel, threejs_prop in _THREEJS_TEX_MAP.items(): - if channel in textures: - result[threejs_prop] = _to_data_uri(textures[channel]) - - return result - - -# Channel → glTF texture property name -_GLTF_TEX_MAP = { - "color": "baseColorTexture", - "normal": "normalTexture", - "ao": "occlusionTexture", - "emission": "emissiveTexture", -} + return _to_threejs(_extract_scalars(material), _extract_textures(material)) def to_gltf(material: Material) -> dict[str, Any]: - """Format as a glTF material dict. - - Note: glTF packs metalness (B) and roughness (G) into a single - metallicRoughnessTexture. This adapter does NOT composite them — - it sets scalar factors and separate textures. A full glTF exporter - would need to pack the channels. + """Format as a glTF pbrMetallicRoughness material dict. - Returns: - Dict conforming to the glTF material spec. + Delegates to mat-vis's generic adapter. Note: glTF packing of + metalness + roughness into one texture is handled by the + mat-vis adapter. """ - pbr = material.properties.pbr - pbr_mr: dict[str, Any] = { - "baseColorFactor": list(pbr.base_color), - "metallicFactor": pbr.metallic, - "roughnessFactor": pbr.roughness, - } - - textures = material.vis.textures if material.vis.source_id else {} - - result: dict[str, Any] = { - "name": material.name, - "pbrMetallicRoughness": pbr_mr, - } - - # Texture references (as data URIs) - for channel, gltf_prop in _GLTF_TEX_MAP.items(): - if channel in textures: - result[gltf_prop] = { - "source": _to_data_uri(textures[channel]), - } - - if any(c > 0 for c in pbr.emissive): - result["emissiveFactor"] = list(pbr.emissive) - - if pbr.transmission > 0.0: - result["extensions"] = { - "KHR_materials_transmission": { - "transmissionFactor": pbr.transmission, - } - } - + result = _to_gltf(_extract_scalars(material), _extract_textures(material)) + result["name"] = material.name return result def export_mtlx(material: Material, output_dir: Path) -> Path: """Export as a MaterialX .mtlx file + PNG textures on disk. - Writes: - output_dir/ - .mtlx - _color.png - _normal.png - ... - - Args: - material: Material with vis data. - output_dir: Directory to write into (created if needed). - - Returns: - Path to the written .mtlx file. + Delegates to mat-vis's generic adapter. """ - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) safe_name = material.name.replace(" ", "_").replace("/", "_") - pbr = material.properties.pbr - textures = material.vis.textures if material.vis.source_id else {} - - # Write PNG files - tex_refs: dict[str, str] = {} - for channel, png_bytes in textures.items(): - filename = f"{safe_name}_{channel}.png" - (output_dir / filename).write_bytes(png_bytes) - tex_refs[channel] = filename - - # Build MaterialX XML - root = Element("materialx", version="1.39") - root.set("xmlns", "http://www.materialx.org/") - - # Comment with provenance - graph = SubElement(root, "nodegraph", name=f"{safe_name}_graph") - - # Image nodes for each texture - for channel, filename in tex_refs.items(): - img = SubElement(graph, "image", name=f"{channel}_tex", type="color3") - inp = SubElement(img, "input", name="file", type="filename") - inp.set("value", filename) - - # Standard surface node - surface = SubElement(graph, "standard_surface", name="surface", type="surfaceshader") - - # Base color - if "color" in tex_refs: - inp = SubElement(surface, "input", name="base_color", type="color3") - inp.set("nodename", "color_tex") - else: - inp = SubElement(surface, "input", name="base_color", type="color3") - inp.set("value", f"{pbr.base_color[0]}, {pbr.base_color[1]}, {pbr.base_color[2]}") - - # Normal - if "normal" in tex_refs: - inp = SubElement(surface, "input", name="normal", type="vector3") - inp.set("nodename", "normal_tex") - - # Roughness - inp = SubElement(surface, "input", name="specular_roughness", type="float") - inp.set("value", str(pbr.roughness)) - - # Metallic - inp = SubElement(surface, "input", name="metalness", type="float") - inp.set("value", str(pbr.metallic)) - - # Material node - mat = SubElement(root, "material", name=f"{safe_name}_mat") - SubElement(mat, "shaderref", name="surface_ref", node="surface") - - # Write - mtlx_path = output_dir / f"{safe_name}.mtlx" - indent(root, space=" ") - tree = ElementTree(root) - tree.write(str(mtlx_path), xml_declaration=True, encoding="utf-8") - - return mtlx_path + return _export_mtlx( + _extract_scalars(material), + _extract_textures(material), + output_dir, + material_name=safe_name, + ) diff --git a/tests/test_vis.py b/tests/test_vis.py index cbfbc95..2c4dc1c 100644 --- a/tests/test_vis.py +++ b/tests/test_vis.py @@ -260,38 +260,45 @@ def test_import_vis(self): def test_get_manifest_returns_dict(self): from pymat import vis - manifest = vis.get_manifest(release_tag="v0.1.0") + manifest = vis.get_manifest() assert "release_tag" in manifest - assert manifest["release_tag"] == "v0.1.0" + assert "tiers" in manifest def test_search_with_mock(self, monkeypatch): - """Search against a mock index (no network).""" + """Search against a mock client (no network).""" from pymat import vis from pymat.vis import _client - mock_index = [ + mock_results = [ {"id": "Metal001", "source": "ambientcg", "category": "metal", "roughness": 0.3, "metalness": 1.0}, - {"id": "Wood001", "source": "ambientcg", "category": "wood", "roughness": 0.6}, ] - def mock_fetch_json(url, cache_path=None): - if "ambientcg" in url: - return mock_index - raise ConnectionError("not available") + class MockClient: + def __init__(self, **kw): pass + @property + def manifest(self): return {"release_tag": "mock", "tiers": {}} + def sources(self, tier="1k"): return ["ambientcg"] + def index(self, source): return mock_results + def search(self, **kw): return mock_results - monkeypatch.setattr(_client, "_fetch_json", mock_fetch_json) + monkeypatch.setattr(_client, "_client", MockClient()) results = vis.search(category="metal") - assert len(results) == 1 + assert len(results) >= 1 assert results[0]["id"] == "Metal001" def test_rowmap_entry_missing_material_raises(self, monkeypatch): from pymat import vis from pymat.vis import _client - mock_rowmap = {"version": 1, "materials": {"Existing001": {"color": {"offset": 0, "length": 100}}}} + class MockClient: + def __init__(self, **kw): pass + @property + def manifest(self): return {"release_tag": "mock", "tiers": {}} + def rowmap_entry(self, source, mid, **kw): + raise KeyError(f"NotExist") - monkeypatch.setattr(_client, "_fetch_json", lambda url, cache_path=None: mock_rowmap) + monkeypatch.setattr(_client, "_client", MockClient()) with pytest.raises(KeyError, match="NotExist"): vis.rowmap_entry("ambientcg", "NotExist") From 78a891fc561574f54127d48619f0d34e510f12d5 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 11:28:59 +0200 Subject: [PATCH 11/32] fix: re-vendor mat-vis client (partitioned rowmap support) + metalness mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-downloaded mat_vis_client.py from v2026.04.0 — now handles category-partitioned rowmaps (merges per-category rowmaps into one) - Fixed metallic→metalness field name mapping in adapters wrapper (py-mat uses "metallic", mat-vis uses "metalness") - Closes mat-vis#40 on our side Verified end-to-end: Material → vis.fetch → 4 PNG channels → to_threejs → MeshPhysicalMaterial with all texture maps. 133 passed, 26 skipped. --- src/pymat/vis/_vendor_client.py | 49 +++++++++++++++++++++------------ src/pymat/vis/adapters.py | 4 ++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/pymat/vis/_vendor_client.py b/src/pymat/vis/_vendor_client.py index 102edaf..45713e2 100644 --- a/src/pymat/vis/_vendor_client.py +++ b/src/pymat/vis/_vendor_client.py @@ -124,34 +124,47 @@ def tiers(self) -> list[str]: return list(self.manifest.get("tiers", {}).keys()) def rowmap(self, source: str, tier: str, category: str | None = None) -> dict: - """Fetch and cache a rowmap.""" + """Fetch and cache rowmaps. Merges partitioned rowmaps into one.""" key = f"{source}-{tier}-{category or 'all'}" if key not in self._rowmaps: tier_data = self.manifest["tiers"][tier] base_url = tier_data["base_url"] src_data = tier_data["sources"][source] - # Find matching rowmap file rowmap_files = src_data.get("rowmap_files", []) if not rowmap_files: - # Legacy single rowmap rowmap_file = src_data.get("rowmap_file", f"{source}-{tier}-rowmap.json") rowmap_files = [rowmap_file] if category: - matches = [f for f in rowmap_files if category in f] - rowmap_file = matches[0] if matches else rowmap_files[0] - else: - rowmap_file = rowmap_files[0] - - cache_path = self._cache_dir / ".rowmaps" / rowmap_file - if cache_path.exists(): - self._rowmaps[key] = json.loads(cache_path.read_text()) - else: - url = base_url + rowmap_file - self._rowmaps[key] = _get_json(url) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(json.dumps(self._rowmaps[key], indent=2)) + rowmap_files = [f for f in rowmap_files if category in f] or rowmap_files[:1] + + # Fetch all partition rowmaps and merge materials + merged: dict = {"materials": {}} + for rmf in rowmap_files: + cache_path = self._cache_dir / ".rowmaps" / rmf + if cache_path.exists(): + rm = json.loads(cache_path.read_text()) + else: + url = base_url + rmf + rm = _get_json(url) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(rm, indent=2)) + + # Each partitioned rowmap has its own parquet_file + pq_file = rm.get("parquet_file", "") + for mid, channels in rm.get("materials", {}).items(): + # Tag each channel with its parquet file for range reads + for ch_data in channels.values(): + ch_data["parquet_file"] = pq_file + merged["materials"][mid] = channels + + # Keep metadata from last rowmap (they're all the same except materials) + for k in ("version", "release_tag", "source", "tier"): + if k in rm: + merged[k] = rm[k] + + self._rowmaps[key] = merged return self._rowmaps[key] @@ -315,10 +328,10 @@ def fetch_texture( offset = rng["offset"] length = rng["length"] - # Find parquet URL + # Find parquet URL (per-partition from merged rowmap) tier_data = self.manifest["tiers"][tier] base_url = tier_data["base_url"] - parquet_file = rm["parquet_file"] + parquet_file = rng.get("parquet_file") or rm.get("parquet_file", "") url = base_url + parquet_file # HTTP range read diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index 535917c..68ce4e0 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -27,8 +27,10 @@ def _extract_scalars(material: Material) -> dict[str, Any]: """Extract PBR scalars from Material.properties.pbr as a plain dict.""" pbr = material.properties.pbr + # Map py-mat field names → mat-vis field names + # See docs/specs/field-name-mapping.md: py-mat "metallic" → mat-vis "metalness" scalars: dict[str, Any] = { - "metallic": pbr.metallic, + "metalness": pbr.metallic, "roughness": pbr.roughness, "base_color": pbr.base_color, "ior": pbr.ior, From c15f4ce8661f839ad767965bbcd880f0540560fc Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 11:33:37 +0200 Subject: [PATCH 12/32] feat: add catalog generator script (markdown + thumbnails) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/generate_catalog.py: - Iterates all TOML-registered materials (95 across 7 categories) - Generates docs/catalog/ tree: root index → category pages → per-material detail pages - Each page has: identity, mechanical, thermal, PBR, composition, vis source/finishes - Category index: table with material, density, roughness, metallic - Optional thumbnails: fetches color texture from mat-vis, resizes to 128x128 via Pillow - --skip-thumbnails for text-only (CI without mat-vis access) Designed for CI: .github/workflows/catalog.yml runs on push to dev, commits generated docs to a gh-pages branch or PR. 133 passed, 26 skipped. --- scripts/generate_catalog.py | 298 ++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 scripts/generate_catalog.py diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py new file mode 100644 index 0000000..4935819 --- /dev/null +++ b/scripts/generate_catalog.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Generate a markdown material catalog with thumbnails from mat-vis. + +Outputs a docs/catalog/ tree with per-category index pages and +per-material detail pages, each with a small thumbnail PNG from +the mat-vis color texture. + +Usage: + python scripts/generate_catalog.py # generate to docs/catalog/ + python scripts/generate_catalog.py --output /tmp/cat # custom output dir + python scripts/generate_catalog.py --skip-thumbnails # text only, no vis fetch + +Called by .github/workflows/catalog.yml on push to dev. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from io import BytesIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from pymat import load_all + +log = logging.getLogger("catalog") + +THUMB_SIZE = (128, 128) +CATEGORIES_ORDER = [ + "metals", "scintillators", "ceramics", "plastics", + "electronics", "liquids", "gases", +] + + +def _make_thumbnail(png_bytes: bytes, size: tuple[int, int] = THUMB_SIZE) -> bytes: + """Resize a PNG to a thumbnail. Returns PNG bytes.""" + try: + from PIL import Image + except ImportError: + log.warning("Pillow not installed — skipping thumbnail") + return b"" + + try: + img = Image.open(BytesIO(png_bytes)) + img.load() # force full decode before resize + img.thumbnail(size, Image.LANCZOS) + out = BytesIO() + img.save(out, format="PNG") + return out.getvalue() + except Exception as exc: + log.debug("thumbnail resize failed: %s", exc) + return b"" + + +def _format_value(key: str, value, unit: str | None = None) -> str: + """Format a property value for markdown.""" + if value is None: + return "—" + if isinstance(value, float): + formatted = f"{value:.4g}" + else: + formatted = str(value) + if unit: + formatted += f" {unit}" + return formatted + + +def _material_page(mat, thumb_path: str | None, category: str) -> str: + """Generate markdown for a single material detail page.""" + lines = [f"# {mat.name}", ""] + + if thumb_path: + lines.append(f"![{mat.name}]({thumb_path})") + lines.append("") + + # Identity + lines.append("## Identity") + lines.append("") + lines.append(f"| Field | Value |") + lines.append(f"|---|---|") + if mat.formula: + lines.append(f"| Formula | `{mat.formula}` |") + if mat.grade: + lines.append(f"| Grade | {mat.grade} |") + if mat.temper: + lines.append(f"| Temper | {mat.temper} |") + if mat.treatment: + lines.append(f"| Treatment | {mat.treatment} |") + lines.append("") + + # Mechanical + mech = mat.properties.mechanical + if mech.density is not None or mech.youngs_modulus is not None: + lines.append("## Mechanical Properties") + lines.append("") + lines.append("| Property | Value |") + lines.append("|---|---|") + if mech.density is not None: + lines.append(f"| Density | {mech.density} g/cm³ |") + if mech.youngs_modulus is not None: + lines.append(f"| Young's Modulus | {mech.youngs_modulus} GPa |") + if mech.yield_strength is not None: + lines.append(f"| Yield Strength | {mech.yield_strength} MPa |") + if mech.tensile_strength is not None: + lines.append(f"| Tensile Strength | {mech.tensile_strength} MPa |") + if mech.poissons_ratio is not None: + lines.append(f"| Poisson's Ratio | {mech.poissons_ratio} |") + if mech.hardness_vickers is not None: + lines.append(f"| Hardness (Vickers) | {mech.hardness_vickers} |") + lines.append("") + + # Thermal + therm = mat.properties.thermal + if therm.melting_point is not None: + lines.append("## Thermal Properties") + lines.append("") + lines.append("| Property | Value |") + lines.append("|---|---|") + if therm.melting_point is not None: + lines.append(f"| Melting Point | {therm.melting_point} °C |") + if therm.thermal_conductivity is not None: + lines.append(f"| Thermal Conductivity | {therm.thermal_conductivity} W/(m·K) |") + if therm.specific_heat is not None: + lines.append(f"| Specific Heat | {therm.specific_heat} J/(kg·K) |") + lines.append("") + + # PBR + pbr = mat.properties.pbr + lines.append("## PBR (Rendering)") + lines.append("") + lines.append("| Property | Value |") + lines.append("|---|---|") + lines.append(f"| Base Color | `{pbr.base_color}` |") + lines.append(f"| Metallic | {pbr.metallic} |") + lines.append(f"| Roughness | {pbr.roughness} |") + if pbr.ior != 1.5: + lines.append(f"| IOR | {pbr.ior} |") + if pbr.transmission > 0: + lines.append(f"| Transmission | {pbr.transmission} |") + lines.append("") + + # Vis + if mat.vis.source_id: + lines.append("## Visual (mat-vis)") + lines.append("") + lines.append(f"| Field | Value |") + lines.append(f"|---|---|") + lines.append(f"| Source ID | `{mat.vis.source_id}` |") + if mat.vis.finish: + lines.append(f"| Finish | {mat.vis.finish} |") + if mat.vis.finishes: + finishes = ", ".join(mat.vis.finishes.keys()) + lines.append(f"| Available Finishes | {finishes} |") + lines.append("") + + # Composition + if mat.composition: + lines.append("## Composition") + lines.append("") + lines.append("| Element | Fraction |") + lines.append("|---|---|") + for el, frac in sorted(mat.composition.items(), key=lambda x: -x[1]): + lines.append(f"| {el} | {frac:.4g} |") + lines.append("") + + return "\n".join(lines) + + +def _category_index(category: str, materials: list, has_thumbnails: bool) -> str: + """Generate markdown index for a category.""" + lines = [f"# {category.title()}", ""] + + if has_thumbnails: + lines.append("| Material | Thumbnail | Density | Roughness | Metallic |") + lines.append("|---|---|---|---|---|") + else: + lines.append("| Material | Density | Roughness | Metallic |") + lines.append("|---|---|---|---|") + + for mat, key in materials: + mech = mat.properties.mechanical + pbr = mat.properties.pbr + density = f"{mech.density} g/cm³" if mech.density else "—" + link = f"[{mat.name}]({key}.md)" + + if has_thumbnails and mat.vis.source_id: + thumb = f"![thumb](thumbs/{key}.png)" + lines.append(f"| {link} | {thumb} | {density} | {pbr.roughness} | {pbr.metallic} |") + elif has_thumbnails: + lines.append(f"| {link} | — | {density} | {pbr.roughness} | {pbr.metallic} |") + else: + lines.append(f"| {link} | {density} | {pbr.roughness} | {pbr.metallic} |") + + lines.append("") + return "\n".join(lines) + + +def _root_index(categories: dict[str, list]) -> str: + """Generate root README with links to categories.""" + lines = ["# Material Catalog", ""] + lines.append("Auto-generated from py-mat TOML data + mat-vis textures.") + lines.append("") + lines.append("| Category | Materials |") + lines.append("|---|---|") + for cat, mats in categories.items(): + lines.append(f"| [{cat.title()}]({cat}/README.md) | {len(mats)} |") + lines.append("") + return "\n".join(lines) + + +def generate(output_dir: Path, skip_thumbnails: bool = False) -> None: + """Generate the full catalog.""" + output_dir.mkdir(parents=True, exist_ok=True) + all_materials = load_all() + + # Group by category (from the TOML key hierarchy) + from pymat import _CATEGORY_BASES + categories: dict[str, list] = {} + + for category, base_keys in _CATEGORY_BASES.items(): + mats_in_cat = [] + for key in base_keys: + mat = all_materials.get(key) + if mat: + mats_in_cat.append((mat, key)) + # Also add children + for child_key, child_mat in mat._children.items(): + mats_in_cat.append((child_mat, f"{key}-{child_key}")) + if mats_in_cat: + categories[category] = mats_in_cat + + # Fetch thumbnails + thumb_count = 0 + if not skip_thumbnails: + try: + from pymat import vis + for category, mats in categories.items(): + thumb_dir = output_dir / category / "thumbs" + thumb_dir.mkdir(parents=True, exist_ok=True) + for mat, key in mats: + if not mat.vis.source_id: + continue + thumb_path = thumb_dir / f"{key}.png" + if thumb_path.exists(): + thumb_count += 1 + continue + try: + textures = mat.vis.textures + if "color" in textures: + thumb_bytes = _make_thumbnail(textures["color"]) + if thumb_bytes: + thumb_path.write_bytes(thumb_bytes) + thumb_count += 1 + log.info("thumbnail: %s", key) + except Exception: + log.debug("thumbnail failed: %s", key, exc_info=True) + except Exception: + log.warning("mat-vis not available — skipping thumbnails") + + has_thumbnails = thumb_count > 0 + + # Generate pages + for category, mats in categories.items(): + cat_dir = output_dir / category + cat_dir.mkdir(parents=True, exist_ok=True) + + # Category index + (cat_dir / "README.md").write_text(_category_index(category, mats, has_thumbnails)) + + # Per-material pages + for mat, key in mats: + thumb_rel = f"thumbs/{key}.png" if (cat_dir / "thumbs" / f"{key}.png").exists() else None + page = _material_page(mat, thumb_rel, category) + (cat_dir / f"{key}.md").write_text(page) + + # Root index + (output_dir / "README.md").write_text(_root_index(categories)) + + total_mats = sum(len(m) for m in categories.values()) + print(f"Catalog: {total_mats} materials, {len(categories)} categories, {thumb_count} thumbnails") + print(f"Output: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", "-o", default="docs/catalog", help="Output directory") + parser.add_argument("--skip-thumbnails", action="store_true", help="Skip vis texture fetch") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + generate(Path(args.output), skip_thumbnails=args.skip_thumbnails) + + +if __name__ == "__main__": + main() From 224187928118e797b9b3d749b1f4921b182a17be Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 11:47:40 +0200 Subject: [PATCH 13/32] docs: add CONTRIBUTING.md + material request/correction issue templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md: - Three paths: request a material, add a material (TOML template), fix a value - Data quality guidelines (cite sources, SI units, don't fabricate) - Vis mapping guide (vis.search → TOML [vis] section) - Standard PR workflow (fork, branch from dev, test, lint) Issue templates: - material-request.yml: name, category, known properties, use case, datasheet URL, desired appearance - material-correction.yml: material, property, current/correct value, source citation --- .../ISSUE_TEMPLATE/material-correction.yml | 44 +++++++ .github/ISSUE_TEMPLATE/material-request.yml | 65 ++++++++++ CONTRIBUTING.md | 120 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/material-correction.yml create mode 100644 .github/ISSUE_TEMPLATE/material-request.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/material-correction.yml b/.github/ISSUE_TEMPLATE/material-correction.yml new file mode 100644 index 0000000..554a2bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/material-correction.yml @@ -0,0 +1,44 @@ +name: Material data correction +description: Report an incorrect property value or missing data +labels: ["data-correction"] +body: + - type: input + id: material + attributes: + label: Material + description: Which material has the issue + placeholder: e.g. stainless.s316L, aluminum.a6061 + validations: + required: true + + - type: input + id: property + attributes: + label: Property + description: Which property is wrong or missing + placeholder: e.g. density, yield_strength, thermal_conductivity + validations: + required: true + + - type: input + id: current_value + attributes: + label: Current value (if wrong) + placeholder: e.g. 8.0 g/cm³ + + - type: input + id: correct_value + attributes: + label: Correct value + placeholder: e.g. 7.99 g/cm³ + validations: + required: true + + - type: input + id: source + attributes: + label: Source / reference + description: Where does the correct value come from? + placeholder: e.g. ASM Handbook Vol. 2, MatWeb, manufacturer datasheet URL + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/material-request.yml b/.github/ISSUE_TEMPLATE/material-request.yml new file mode 100644 index 0000000..e92a6cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/material-request.yml @@ -0,0 +1,65 @@ +name: Material request +description: Request a new material to be added to the library +labels: ["material-request"] +body: + - type: input + id: material_name + attributes: + label: Material name + description: Common name or trade name + placeholder: e.g. Inconel 718, Sapphire, Carbon Fiber T300 + validations: + required: true + + - type: dropdown + id: category + attributes: + label: Category + options: + - Metal / Alloy + - Ceramic + - Plastic / Polymer + - Scintillator / Detector + - Glass + - Composite + - Electronics + - Liquid + - Gas + - Other + validations: + required: true + + - type: textarea + id: properties + attributes: + label: Known properties + description: | + Any values you already know. Don't worry about completeness — + we'll fill in the rest from datasheets / literature. + placeholder: | + Density: 8.19 g/cm³ + Yield strength: 1034 MPa + Melting point: 1260 °C + Composition: Ni 52%, Cr 19%, Fe 18%, Nb 5%, Mo 3% + + - type: textarea + id: use_case + attributes: + label: Use case + description: What are you using this material for? Helps us prioritize. + placeholder: e.g. PET scanner shielding, 3D printed bracket, Geant4 simulation + + - type: input + id: datasheet_url + attributes: + label: Datasheet or reference URL (optional) + placeholder: https://www.matweb.com/... + + - type: textarea + id: appearance + attributes: + label: Visual appearance (optional) + description: | + If you need PBR textures, describe the finish. We'll try to + match from mat-vis's texture library. + placeholder: e.g. brushed finish, polished mirror, matte black anodized diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4eb3a2a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Contributing to mat (py-materials) + +## Ways to contribute + +### Request a material + +Don't have the data? [Open a material request][request] — tell us +what you need and why. We'll source the properties from literature +and datasheets. + +### Add a material + +Have the data? Open a PR adding a TOML entry. Here's the template: + +```toml +# src/pymat/data/.toml +# Add under the appropriate category file (metals.toml, plastics.toml, etc.) + +[my_material] +name = "My Material Name" +formula = "Fe3O4" # optional — chemical formula +composition = {Fe = 0.72, O = 0.28} # optional — element mass fractions + +[my_material.mechanical] +density_value = 5.17 +density_unit = "g/cm^3" +youngs_modulus_value = 200 # optional +youngs_modulus_unit = "GPa" + +[my_material.thermal] +melting_point_value = 1597 # optional +melting_point_unit = "degC" + +[my_material.pbr] +base_color = [0.1, 0.1, 0.1, 1.0] # RGBA, 0-1 +metallic = 0.8 # 0 = dielectric, 1 = metal +roughness = 0.5 # 0 = glossy, 1 = rough + +# Visual appearance mapping (optional — mat-vis must have matching textures) +[my_material.vis] +default = "natural" + +[my_material.vis.finishes] +natural = "ambientcg/Metal_SomeID" # mat-vis source ID +``` + +#### What's required + +- `name` — human-readable +- `density` — in `g/cm³` (needed for `compute_mass()`) +- `base_color`, `metallic`, `roughness` — for rendering + +Everything else is optional. More data is better, but partial +entries are welcome — we can enrich later. + +#### Hierarchy + +Materials support parent → child hierarchy: + +```toml +[aluminum] +name = "Aluminum" +# base properties... + +[aluminum.a6061] +name = "Aluminum 6061-T6" +grade = "6061" +temper = "T6" +# only overrides — inherits everything from parent +``` + +### Fix a value + +Spotted an error? [Open a correction issue][correction] with the +correct value and a source citation. Or open a PR directly — +edit the TOML, cite your source in the commit message. + +### Improve the code + +See the [open issues][issues] for bugs, features, and +refactoring tasks. The standard workflow: + +1. Fork the repo +2. Create a branch from `dev` +3. Make your changes +4. Run tests: `pytest tests/ -v` +5. Run linter: `ruff check src/ tests/` +6. Open a PR against `dev` + +## Data quality + +- **Cite your sources.** Every property value should be traceable + to a datasheet, handbook, or paper. Include the reference in the + commit message or PR description. +- **Use SI-compatible units** with the `_value` / `_unit` suffix + pattern (e.g. `density_value = 8.0`, `density_unit = "g/cm^3"`). +- **Don't fabricate values.** If a property isn't known, leave it + out. `None` is better than a guess. +- **PBR values can be approximate.** Rendering properties are + visual, not physical measurements. Matching "looks like steel" + is fine. + +## Visual appearance (vis) + +Materials can optionally link to [mat-vis][mat-vis] textures for +PBR rendering. To find matching textures: + +```python +from pymat import vis +vis.search(category="metal", roughness=0.3) +``` + +Add the match to the `[vis]` section of the TOML. If you're +unsure, leave it out — the `enrich_vis.py` script proposes +matches automatically. + +[request]: https://github.com/MorePET/mat/issues/new?template=material-request.yml +[correction]: https://github.com/MorePET/mat/issues/new?template=material-correction.yml +[issues]: https://github.com/MorePET/mat/issues +[mat-vis]: https://github.com/MorePET/mat-vis From a40b697b6ddb5945baa2655aa350a2ba4a5d90c8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 11:55:41 +0200 Subject: [PATCH 14/32] docs: link catalog, contributing, mat-vis in README - Installation section simplified (no stale extras) - Added Material Catalog section linking to docs/catalog/ - Added Contributing section with issue template links - Added mat-vis link to Links section - Added [mat-vis] reference link --- README.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e028a90..72cdbfd 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,14 @@ A hierarchical material library for CAD applications and Monte Carlo particle tr ## Installation ```bash -# From PyPI (recommended) pip install py-materials # or: uv add py-materials - -# From the main branch (development) -pip install git+https://github.com/MorePET/mat.git@main - -# With optional extras -pip install "py-materials[periodictable]" # auto-fill from chemical formulas -pip install "py-materials[build123d]" # build123d Shape integration (Python <= 3.12) -pip install "py-materials[all]" # everything above ``` +Core install includes `pint` + `periodictable`. No extras needed — +PBR textures are fetched on demand from [mat-vis][mat-vis] via +pure-Python HTTP range reads (zero binary deps). + ## Quick Start ## Creating Materials @@ -559,6 +554,21 @@ assert alumina.density is None # use enrich_from_matproj for compounds These can differ intentionally! A material might be physically transparent (95% optical transmission) but rendered opaque (0% pbr transmission) for CAD clarity. +## Material Catalog + +Browse all materials with properties and thumbnails: +[**docs/catalog/**](docs/catalog/) + +Generated from TOML data + [mat-vis][mat-vis] textures via +`python scripts/generate_catalog.py`. + +## Contributing + +[**CONTRIBUTING.md**](CONTRIBUTING.md) — how to request, add, or +correct materials. Issue templates for +[material requests](https://github.com/MorePET/mat/issues/new?template=material-request.yml) +and [data corrections](https://github.com/MorePET/mat/issues/new?template=material-correction.yml). + ## License MIT @@ -578,3 +588,6 @@ and the conditions under which the decision should be revisited. - **Issues**: https://github.com/MorePET/mat/issues - **PyPI**: https://pypi.org/project/py-materials/ - **Rust crate** (`rs-materials`): https://crates.io/crates/rs-materials +- **PBR textures** (`mat-vis`): https://github.com/MorePET/mat-vis + +[mat-vis]: https://github.com/MorePET/mat-vis From 125fff754596de6b8015d11b7521f9e55c17b020 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 12:58:20 +0200 Subject: [PATCH 15/32] refactor: catalog thumbnails from mat-vis 128px tier, no Pillow needed mat-vis now hosts pre-baked thumbnail tiers (128/256/512). The catalog script fetches color textures at the 128px tier directly instead of downloading 1K textures and resizing with Pillow. Simpler, faster, no Pillow dependency for catalog generation. --- scripts/generate_catalog.py | 72 ++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py index 4935819..7db3252 100644 --- a/scripts/generate_catalog.py +++ b/scripts/generate_catalog.py @@ -18,7 +18,6 @@ import argparse import logging import sys -from io import BytesIO from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) @@ -27,30 +26,26 @@ log = logging.getLogger("catalog") -THUMB_SIZE = (128, 128) +THUMB_TIER = "128" # mat-vis hosts 128/256/512 thumbnail tiers CATEGORIES_ORDER = [ "metals", "scintillators", "ceramics", "plastics", "electronics", "liquids", "gases", ] -def _make_thumbnail(png_bytes: bytes, size: tuple[int, int] = THUMB_SIZE) -> bytes: - """Resize a PNG to a thumbnail. Returns PNG bytes.""" - try: - from PIL import Image - except ImportError: - log.warning("Pillow not installed — skipping thumbnail") - return b"" +def _fetch_thumbnail(source: str, material_id: str) -> bytes: + """Fetch a thumbnail PNG from mat-vis's thumbnail tier. + mat-vis hosts 128/256/512 tiers as pre-baked small textures — + no Pillow resize needed, just fetch the color channel at the + thumbnail tier. + """ try: - img = Image.open(BytesIO(png_bytes)) - img.load() # force full decode before resize - img.thumbnail(size, Image.LANCZOS) - out = BytesIO() - img.save(out, format="PNG") - return out.getvalue() + from pymat import vis + textures = vis.fetch(source, material_id, tier=THUMB_TIER) + return textures.get("color", b"") except Exception as exc: - log.debug("thumbnail resize failed: %s", exc) + log.debug("thumbnail fetch failed for %s/%s: %s", source, material_id, exc) return b"" @@ -231,33 +226,28 @@ def generate(output_dir: Path, skip_thumbnails: bool = False) -> None: if mats_in_cat: categories[category] = mats_in_cat - # Fetch thumbnails + # Fetch thumbnails from mat-vis's thumbnail tier (128px, pre-baked) thumb_count = 0 if not skip_thumbnails: - try: - from pymat import vis - for category, mats in categories.items(): - thumb_dir = output_dir / category / "thumbs" - thumb_dir.mkdir(parents=True, exist_ok=True) - for mat, key in mats: - if not mat.vis.source_id: - continue - thumb_path = thumb_dir / f"{key}.png" - if thumb_path.exists(): - thumb_count += 1 - continue - try: - textures = mat.vis.textures - if "color" in textures: - thumb_bytes = _make_thumbnail(textures["color"]) - if thumb_bytes: - thumb_path.write_bytes(thumb_bytes) - thumb_count += 1 - log.info("thumbnail: %s", key) - except Exception: - log.debug("thumbnail failed: %s", key, exc_info=True) - except Exception: - log.warning("mat-vis not available — skipping thumbnails") + for category, mats in categories.items(): + thumb_dir = output_dir / category / "thumbs" + thumb_dir.mkdir(parents=True, exist_ok=True) + for mat, key in mats: + if not mat.vis.source_id: + continue + thumb_path = thumb_dir / f"{key}.png" + if thumb_path.exists(): + thumb_count += 1 + continue + parts = mat.vis.source_id.split("/", 1) + if len(parts) != 2: + continue + source, material_id = parts + thumb_bytes = _fetch_thumbnail(source, material_id) + if thumb_bytes: + thumb_path.write_bytes(thumb_bytes) + thumb_count += 1 + log.info("thumbnail: %s", key) has_thumbnails = thumb_count > 0 From eb0bf1cd9a7c039c862f85e53a688f7bab31bf30 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 13:24:59 +0200 Subject: [PATCH 16/32] fix: adapters read only from .vis, not legacy pbr.*_map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted attempt to merge pbr.*_map fields into adapters. Per our design: all visual data lives under .vis. Legacy pbr.*_map fields (normal_map, roughness_map, etc.) continue to work via ocp_vscode's existing is_pymat path — that's Bernhard's migration to handle when adopting the adapter pattern. We don't bridge legacy into the new API. Deprecation of pbr.*_map → .vis migration tracked for 2.3.0. --- src/pymat/vis/adapters.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index 68ce4e0..65f46ae 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -42,7 +42,12 @@ def _extract_scalars(material: Material) -> dict[str, Any]: def _extract_textures(material: Material) -> dict[str, bytes]: - """Extract texture bytes from Material.vis (if available).""" + """Extract texture bytes from Material.vis. + + Only reads from .vis (the correct namespace for visual data). + Legacy pbr.*_map fields are NOT merged here — those are handled + by ocp_vscode's existing is_pymat path until deprecated. + """ if material.vis.source_id is None: return {} return material.vis.textures From 5ed93c98a0207d39a89493b5ecd9cef39aaf9c11 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 13:39:40 +0200 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20vis=20owns=20PBR=20scalars=20?= =?UTF-8?q?=E2=80=94=20accept=20[vis]=20section=20in=20TOML,=20sync=20to?= =?UTF-8?q?=20properties.pbr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration path toward 3.0 (mat#40): - Vis dataclass gains PBR scalar fields (roughness, metallic, base_color, ior, transmission, clearcoat, emissive) - Vis.from_toml() parses scalars from [vis] section - Loader syncs vis scalars → properties.pbr for backward compat (ocp_vscode's is_pymat path still reads properties.pbr) - Adapters read vis scalars first, fall back to properties.pbr - stainless TOML migrated: [pbr] → [vis] as proof-of-concept Both [pbr] and [vis] TOML sections accepted. vis wins when both present. No breakage — single migration path to 3.0. 133 passed, 26 skipped. --- src/pymat/data/metals.toml | 4 +--- src/pymat/loader.py | 19 +++++++++++++++++++ src/pymat/vis/_model.py | 35 +++++++++++++++++++++++++++++++++-- src/pymat/vis/adapters.py | 24 ++++++++++++++---------- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index f13ed6a..fb863c9 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -23,13 +23,11 @@ specific_heat_unit = "J/(kg*K)" thermal_expansion_value = 16e-6 thermal_expansion_unit = "1/K" -[stainless.pbr] +[stainless.vis] base_color = [0.75, 0.75, 0.77, 1.0] metallic = 1.0 roughness = 0.3 transmission = 0.0 - -[stainless.vis] default = "brushed" [stainless.vis.finishes] diff --git a/src/pymat/loader.py b/src/pymat/loader.py index d7576f5..946baf9 100644 --- a/src/pymat/loader.py +++ b/src/pymat/loader.py @@ -213,6 +213,25 @@ def _resolve_material_node( material._vis = Vis.from_toml(vis_data) + # Sync vis scalars → properties.pbr for backward compat. + # vis values win over [pbr] values when both are present. + vis = material._vis + pbr = material.properties.pbr + if vis.roughness is not None: + pbr.roughness = vis.roughness + if vis.metallic is not None: + pbr.metallic = vis.metallic + if vis.base_color is not None: + pbr.base_color = vis.base_color + if vis.ior is not None: + pbr.ior = vis.ior + if vis.transmission is not None: + pbr.transmission = vis.transmission + if vis.clearcoat is not None: + pbr.clearcoat = vis.clearcoat + if vis.emissive is not None: + pbr.emissive = vis.emissive + # Register for direct access registry.register(key, material) diff --git a/src/pymat/vis/_model.py b/src/pymat/vis/_model.py index f9b30bf..78e412d 100644 --- a/src/pymat/vis/_model.py +++ b/src/pymat/vis/_model.py @@ -41,6 +41,19 @@ class Vis: source_id: str | None = None tier: str = "1k" finishes: dict[str, str] = field(default_factory=dict) + + # PBR scalars — can be set from [vis] section in TOML. + # When set here, these take precedence over properties.pbr values. + # In 3.0, properties.pbr will be removed and these become the + # single source for rendering scalars. + roughness: float | None = None + metallic: float | None = None + base_color: tuple | None = None + ior: float | None = None + transmission: float | None = None + clearcoat: float | None = None + emissive: tuple | None = None + _finish: str | None = None _textures: dict[str, bytes] = field(default_factory=dict, repr=False) _fetched: bool = False @@ -167,13 +180,22 @@ def _fetch(self) -> None: self._textures = fetch(source, material_id, tier=self.tier) self._fetched = True + _PBR_SCALAR_FIELDS = ("roughness", "metallic", "base_color", "ior", "transmission", "clearcoat", "emissive") + @classmethod def from_toml(cls, vis_data: dict[str, Any]) -> Vis: """Construct from a TOML [vis] section. - Expected TOML structure: + Accepts PBR scalars alongside finishes/source_id. When PBR + scalars are present in [vis], they become the canonical source + and are synced back to properties.pbr for backward compat. + + TOML structure: [material.vis] default = "brushed" + roughness = 0.3 + metallic = 1.0 + base_color = [0.75, 0.75, 0.77, 1.0] [material.vis.finishes] brushed = "ambientcg/Metal_Brushed_001" @@ -188,12 +210,21 @@ def from_toml(cls, vis_data: dict[str, Any]) -> Vis: source_id = finishes[default_finish] finish = default_finish elif finishes: - # No default specified — use first finish finish = next(iter(finishes)) source_id = finishes[finish] + # Extract PBR scalars from [vis] section + scalars = {} + for field in cls._PBR_SCALAR_FIELDS: + if field in vis_data: + val = vis_data[field] + if isinstance(val, list): + val = tuple(val) + scalars[field] = val + return cls( source_id=source_id, finishes=finishes, _finish=finish, + **scalars, ) diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index 65f46ae..f1d4fd9 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -25,18 +25,22 @@ def _extract_scalars(material: Material) -> dict[str, Any]: - """Extract PBR scalars from Material.properties.pbr as a plain dict.""" + """Extract PBR scalars — vis wins, properties.pbr as fallback. + + Reads from material.vis first (the canonical source in 3.0), + falls back to material.properties.pbr (legacy, backward compat). + Maps py-mat "metallic" → mat-vis "metalness". + """ + vis = material.vis pbr = material.properties.pbr - # Map py-mat field names → mat-vis field names - # See docs/specs/field-name-mapping.md: py-mat "metallic" → mat-vis "metalness" scalars: dict[str, Any] = { - "metalness": pbr.metallic, - "roughness": pbr.roughness, - "base_color": pbr.base_color, - "ior": pbr.ior, - "transmission": pbr.transmission, - "clearcoat": pbr.clearcoat, - "emissive": pbr.emissive, + "metalness": vis.metallic if vis.metallic is not None else pbr.metallic, + "roughness": vis.roughness if vis.roughness is not None else pbr.roughness, + "base_color": vis.base_color if vis.base_color is not None else pbr.base_color, + "ior": vis.ior if vis.ior is not None else pbr.ior, + "transmission": vis.transmission if vis.transmission is not None else pbr.transmission, + "clearcoat": vis.clearcoat if vis.clearcoat is not None else pbr.clearcoat, + "emissive": vis.emissive if vis.emissive is not None else pbr.emissive, } return scalars From f4f17056d6acf11d9d640560cf93600b10ba7fa2 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 15:10:33 +0200 Subject: [PATCH 18/32] refactor: replace vendored vis client with mat-vis-client package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete 890 lines of vendored code, import from mat-vis-client: Deleted: _vendor_client.py (444 lines — was mat-vis's client.py) _vendor_adapters.py (280 lines — was mat-vis's adapters.py) _client.py (166 lines — our wrapper around vendored) Remaining pymat/vis/ (354 lines): __init__.py (32) — re-exports from mat_vis_client _model.py (230) — Vis, ResolvedChannel, from_toml, discover adapters.py (92) — Material→dict wrappers Dependencies: mat-vis-client>=2026.4.0 added to core deps (currently installed from git, PyPI publish pending) Test mocks updated: pymat.vis._client → mat_vis_client. 133 passed, 26 skipped. Refs: mat#37, mat-vis#36, mat-vis#50 --- pyproject.toml | 1 + src/pymat/vis/__init__.py | 34 +-- src/pymat/vis/_client.py | 166 ----------- src/pymat/vis/_model.py | 4 +- src/pymat/vis/_vendor_adapters.py | 280 ------------------- src/pymat/vis/_vendor_client.py | 444 ------------------------------ src/pymat/vis/adapters.py | 12 +- tests/test_vis.py | 12 +- 8 files changed, 30 insertions(+), 923 deletions(-) delete mode 100644 src/pymat/vis/_client.py delete mode 100644 src/pymat/vis/_vendor_adapters.py delete mode 100644 src/pymat/vis/_vendor_client.py diff --git a/pyproject.toml b/pyproject.toml index 3831dda..8e06d0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ dependencies = [ "pint>=0.20", "periodictable>=1.6.0", + "mat-vis-client>=2026.4.0", "tomli>=1.0.0; python_version == '3.10'", ] diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index 1ce1f48..b346876 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -5,27 +5,23 @@ from pymat import vis - # Search the mat-vis index (runs locally against cached JSON index) - results = vis.search(category="metal", roughness=0.3) - - # Fetch textures by mat-vis source ID - textures = vis.fetch("ambientcg", "Metal_Brushed_001", tier="1k") - textures["color"] # raw PNG bytes - - # Raw rowmap entry for DIY consumers (JS shim, curl, etc.) - entry = vis.rowmap_entry("ambientcg", "Metal_Brushed_001", tier="1k") - # → {"color": {"offset": 102400, "length": 51200}, ...} - - # URL discovery - manifest = vis.get_manifest(release_tag="v2026.04.0") - -The fetch layer is independent of Material — usable standalone -for any consumer that just wants textures without physics data. - -Material.vis wires into this module for its lazy texture loading. + vis.search(category="metal", roughness=0.3) + vis.fetch("ambientcg", "Metal032", tier="1k") + vis.prefetch("ambientcg", tier="1k") + vis.get_manifest() + vis.rowmap_entry("ambientcg", "Metal032", tier="1k") + +Powered by mat-vis-client (installed separately or from git). +Material.vis wires into this module for lazy texture loading. """ -from pymat.vis._client import fetch, get_manifest, prefetch, rowmap_entry, search +from mat_vis_client import ( + fetch, + get_manifest, + prefetch, + rowmap_entry, + search, +) __all__ = [ "search", diff --git a/src/pymat/vis/_client.py b/src/pymat/vis/_client.py deleted file mode 100644 index 15cc1d6..0000000 --- a/src/pymat/vis/_client.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -mat-vis client — thin wrapper around the vendored mat-vis reference client. - -The actual fetch logic lives in _vendor_client.py (shipped by mat-vis -as a release asset, vendored here). This module adapts it to pymat's -API surface (module-level functions instead of class methods). - -See MorePET/mat#37 for migration context. -""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any - -from pymat.vis._vendor_client import MatVisClient - -log = logging.getLogger(__name__) - -# Singleton client instance — lazy-initialized -_client: MatVisClient | None = None - - -def _get_client() -> MatVisClient: - global _client - if _client is None: - _client = MatVisClient() - # Pre-cache indexes from release assets since they're not in git yet. - # The vendored client tries raw.githubusercontent.com which 404s. - # This workaround seeds the cache so the client finds them locally. - _seed_indexes(_client) - return _client - - -def _seed_indexes(client: MatVisClient) -> None: - """Download index JSONs from release assets into the client's cache. - - Tries the current release tag first, then falls back to older tags. - Workaround for indexes not being in git yet — they ship as release assets. - """ - import urllib.request - from urllib.error import HTTPError, URLError - - manifest = client.manifest - tag = manifest.get("release_tag", "") - cache_dir = client._cache_dir / ".indexes" - cache_dir.mkdir(parents=True, exist_ok=True) - - # Collect all sources from manifest + known defaults - sources = set() - for tier_data in manifest.get("tiers", {}).values(): - sources.update(tier_data.get("sources", {}).keys()) - sources.add("physicallybased") - - # Tags to try in order (current release, then older) - tags_to_try = [tag, "v0.1.0"] if tag != "v0.1.0" else [tag] - base = "https://github.com/MorePET/mat-vis/releases/download" - - for source in sources: - cache_path = cache_dir / f"{source}.json" - if cache_path.exists(): - continue - for t in tags_to_try: - url = f"{base}/{t}/{source}.json" - try: - req = urllib.request.Request(url, headers={"User-Agent": "pymat"}) - with urllib.request.urlopen(req, timeout=30) as resp: - data = resp.read() - cache_path.write_bytes(data) - log.debug("seeded index: %s (from %s)", source, t) - break - except (HTTPError, URLError): - continue - else: - log.debug("index not available for %s", source) - - -def get_manifest( - release_tag: str | None = None, -) -> dict: - """Fetch release manifest (URL discovery for all sources × tiers).""" - client = MatVisClient(tag=release_tag) if release_tag else _get_client() - return client.manifest - - -def search( - *, - category: str | None = None, - roughness: float | None = None, - metalness: float | None = None, - source: str | None = None, - tag: str | None = None, - limit: int = 20, -) -> list[dict[str, Any]]: - """Search the mat-vis index by category and scalar similarity.""" - client = MatVisClient(tag=tag) if tag else _get_client() - - roughness_range = None - if roughness is not None: - roughness_range = (max(0.0, roughness - 0.2), min(1.0, roughness + 0.2)) - - metalness_range = None - if metalness is not None: - metalness_range = (max(0.0, metalness - 0.2), min(1.0, metalness + 0.2)) - - results = client.search( - category=category, - source=source, - roughness_range=roughness_range, - metalness_range=metalness_range, - ) - - # Add score for compatibility with existing callers - for r in results: - score = 0.0 - if roughness is not None and r.get("roughness") is not None: - score += abs(r["roughness"] - roughness) - if metalness is not None and r.get("metalness") is not None: - score += abs(r["metalness"] - metalness) - r["score"] = score - - results.sort(key=lambda r: r["score"]) - return results[:limit] - - -def fetch( - source: str, - material_id: str, - *, - tier: str = "1k", - tag: str | None = None, - cache: bool = True, - cache_dir: Path | None = None, -) -> dict[str, bytes]: - """Fetch textures for a material via rowmap + HTTP range read.""" - client = MatVisClient(tag=tag) if tag else _get_client() - try: - return client.fetch_all_textures(source, material_id, tier=tier) - except Exception as exc: - log.warning("vis.fetch(%s/%s): %s", source, material_id, exc) - return {} - - -def prefetch( - source: str, - *, - tier: str = "1k", - tag: str | None = None, - cache_dir: Path | None = None, -) -> int: - """Download all materials for a source × tier into the local cache.""" - client = MatVisClient(tag=tag) if tag else _get_client() - return client.prefetch(source, tier=tier) - - -def rowmap_entry( - source: str, - material_id: str, - *, - tier: str = "1k", - tag: str | None = None, -) -> dict[str, dict[str, int]]: - """Get raw byte-offset info for DIY consumers.""" - client = MatVisClient(tag=tag) if tag else _get_client() - return client.rowmap_entry(source, material_id, tier=tier) diff --git a/src/pymat/vis/_model.py b/src/pymat/vis/_model.py index 78e412d..df4af08 100644 --- a/src/pymat/vis/_model.py +++ b/src/pymat/vis/_model.py @@ -140,7 +140,7 @@ def discover( # or: steel.vis.discover(category="metal", auto_set=True) """ - from pymat.vis._client import search + from mat_vis_client import search results = search( category=category, @@ -175,7 +175,7 @@ def _fetch(self) -> None: ) source, material_id = parts - from pymat.vis._client import fetch + from mat_vis_client import fetch self._textures = fetch(source, material_id, tier=self.tier) self._fetched = True diff --git a/src/pymat/vis/_vendor_adapters.py b/src/pymat/vis/_vendor_adapters.py deleted file mode 100644 index e591161..0000000 --- a/src/pymat/vis/_vendor_adapters.py +++ /dev/null @@ -1,280 +0,0 @@ -"""mat-vis output format adapters — Three.js, glTF, MaterialX. - -Converts generic scalars + texture bytes into renderer-specific formats. -Pure Python, zero dependencies (uses only stdlib xml.etree for MaterialX). - -All functions take generic dicts — no Material class dependency: - - from adapters import to_threejs, to_gltf, export_mtlx - result = to_threejs(scalars, textures) - -Field name mapping follows docs/specs/field-name-mapping.md. -""" - -from __future__ import annotations - -import base64 -import xml.etree.ElementTree as ET -from pathlib import Path - -# ── Field name mapping tables ─────────────────────────────────── -# -# mat-vis channel name -> renderer property name -# Canonical source: docs/specs/field-name-mapping.md - -_THREEJS_TEX_MAP: dict[str, str] = { - "color": "map", - "normal": "normalMap", - "roughness": "roughnessMap", - "metalness": "metalnessMap", - "ao": "aoMap", - "displacement": "displacementMap", - "emission": "emissiveMap", -} - -_GLTF_TEX_MAP: dict[str, str] = { - "color": "baseColorTexture", - "normal": "normalTexture", - "ao": "occlusionTexture", - "emission": "emissiveTexture", - # roughness + metalness are packed into metallicRoughnessTexture - # handled separately in to_gltf() -} - -_MTLX_TEX_MAP: dict[str, str] = { - "color": "base_color", - "normal": "normal", - "roughness": "specular_roughness", - "metalness": "metalness", - "ao": "occlusion", - "displacement": "displacement", - "emission": "emission_color", -} - - -# ── Helpers ───────────────────────────────────────────────────── - - -def _to_data_uri(png_bytes: bytes) -> str: - """Encode PNG bytes as a base64 data URI.""" - b64 = base64.b64encode(png_bytes).decode("ascii") - return f"data:image/png;base64,{b64}" - - -def _color_hex_to_int(hex_str: str) -> int: - """Convert '#RRGGBB' hex string to an integer (Three.js color format). - - >>> _color_hex_to_int('#A0522D') - 10506797 - """ - return int(hex_str.lstrip("#"), 16) - - -def _color_hex_to_rgba(hex_str: str) -> list[float]: - """Convert '#RRGGBB' to glTF [R, G, B, A] floats in [0, 1].""" - h = hex_str.lstrip("#") - r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) - return [r / 255.0, g / 255.0, b / 255.0, 1.0] - - -# ── Three.js adapter ─────────────────────────────────────────── - - -def to_threejs( - scalars: dict, - textures: dict[str, bytes] | None = None, -) -> dict: - """Convert to a Three.js MeshPhysicalMaterial parameter dict. - - Args: - scalars: Material scalars. Expected keys (all optional): - - metalness (float 0-1) - - roughness (float 0-1) - - color_hex (str '#RRGGBB') - - ior (float) - - transmission (float 0-1) - textures: Channel name -> PNG bytes. Keys are mat-vis channel - names: color, normal, roughness, metalness, ao, - displacement, emission. - - Returns: - Dict suitable for `new THREE.MeshPhysicalMaterial(result)`. - Textures are embedded as base64 data URIs. - """ - textures = textures or {} - result: dict = {"type": "MeshPhysicalMaterial"} - - # Scalars - if "metalness" in scalars and scalars["metalness"] is not None: - result["metalness"] = scalars["metalness"] - if "roughness" in scalars and scalars["roughness"] is not None: - result["roughness"] = scalars["roughness"] - if "color_hex" in scalars and scalars["color_hex"] is not None: - result["color"] = _color_hex_to_int(scalars["color_hex"]) - if "ior" in scalars and scalars["ior"] is not None: - result["ior"] = scalars["ior"] - if "transmission" in scalars and scalars["transmission"] is not None: - result["transmission"] = scalars["transmission"] - - # Textures as data URIs - for channel, prop in _THREEJS_TEX_MAP.items(): - if channel in textures: - result[prop] = _to_data_uri(textures[channel]) - - return result - - -# ── glTF adapter ──────────────────────────────────────────────── - - -def to_gltf( - scalars: dict, - textures: dict[str, bytes] | None = None, -) -> dict: - """Convert to a glTF pbrMetallicRoughness material dict. - - Args: - scalars: Same as to_threejs(). - textures: Same as to_threejs(). - - Returns: - Dict conforming to glTF 2.0 material schema. Textures are - embedded as base64 data URIs in the 'uri' field. Does NOT - pack metalness+roughness into a single texture (that requires - image compositing which needs PIL or similar). Instead, scalar - factors are used when separate maps are provided. - - Note: - Full glTF compliance for metallicRoughnessTexture packing - requires image processing (PIL/Pillow). This adapter provides - a best-effort output using scalar factors and separate texture - references. For production glTF export, consider using a - library like pygltflib. - """ - textures = textures or {} - pbr: dict = {} - material: dict = {"pbrMetallicRoughness": pbr} - - # Scalar factors - if "metalness" in scalars and scalars["metalness"] is not None: - pbr["metallicFactor"] = scalars["metalness"] - if "roughness" in scalars and scalars["roughness"] is not None: - pbr["roughnessFactor"] = scalars["roughness"] - if "color_hex" in scalars and scalars["color_hex"] is not None: - pbr["baseColorFactor"] = _color_hex_to_rgba(scalars["color_hex"]) - - # IOR extension - if "ior" in scalars and scalars["ior"] is not None: - material.setdefault("extensions", {})["KHR_materials_ior"] = {"ior": scalars["ior"]} - - # Transmission extension - if "transmission" in scalars and scalars["transmission"] is not None: - material.setdefault("extensions", {})["KHR_materials_transmission"] = { - "transmissionFactor": scalars["transmission"] - } - - # Textures - def _tex_ref(png_bytes: bytes) -> dict: - return {"source": {"uri": _to_data_uri(png_bytes)}} - - for channel, prop in _GLTF_TEX_MAP.items(): - if channel in textures: - if prop in ("normalTexture", "occlusionTexture", "emissiveTexture"): - material[prop] = _tex_ref(textures[channel]) - else: - pbr[prop] = _tex_ref(textures[channel]) - - # metallicRoughnessTexture: only if BOTH metalness and roughness - # textures are available (proper packing needs image processing, - # so we note this limitation) - if "metalness" in textures and "roughness" in textures: - pbr["_note_metallicRoughnessTexture"] = ( - "Separate metalness and roughness textures provided. " - "Pack into a single metallicRoughnessTexture (B=metal, G=rough) " - "for full glTF compliance." - ) - - return material - - -# ── MaterialX adapter ────────────────────────────────────────── - - -def export_mtlx( - scalars: dict, - textures: dict[str, bytes] | None = None, - output_dir: str | Path = ".", - *, - material_name: str = "Material", -) -> Path: - """Export as MaterialX .mtlx XML with referenced PNG files. - - Args: - scalars: Same as to_threejs(). - textures: Same as to_threejs(). PNGs are written to output_dir. - output_dir: Directory for .mtlx and .png files. - material_name: Name for the material in the .mtlx document. - - Returns: - Path to the written .mtlx file. - """ - textures = textures or {} - out = Path(output_dir) - out.mkdir(parents=True, exist_ok=True) - - # Root element - root = ET.Element("materialx", version="1.38") - - # Standard surface node - sr = ET.SubElement(root, "standard_surface", name=f"SR_{material_name}", type="surfaceshader") - - # Scalar inputs - if "metalness" in scalars and scalars["metalness"] is not None: - ET.SubElement(sr, "input", name="metalness", type="float", value=str(scalars["metalness"])) - if "roughness" in scalars and scalars["roughness"] is not None: - ET.SubElement( - sr, "input", name="specular_roughness", type="float", value=str(scalars["roughness"]) - ) - if "color_hex" in scalars and scalars["color_hex"] is not None: - rgba = _color_hex_to_rgba(scalars["color_hex"]) - color_str = f"{rgba[0]:.4f}, {rgba[1]:.4f}, {rgba[2]:.4f}" - ET.SubElement(sr, "input", name="base_color", type="color3", value=color_str) - if "ior" in scalars and scalars["ior"] is not None: - ET.SubElement(sr, "input", name="specular_IOR", type="float", value=str(scalars["ior"])) - if "transmission" in scalars and scalars["transmission"] is not None: - ET.SubElement( - sr, "input", name="transmission", type="float", value=str(scalars["transmission"]) - ) - - # Write texture PNGs and create image nodes - for channel, png_bytes in textures.items(): - mtlx_input = _MTLX_TEX_MAP.get(channel) - if mtlx_input is None: - continue - - # Write PNG file - png_filename = f"{material_name}_{channel}.png" - png_path = out / png_filename - png_path.write_bytes(png_bytes) - - # Image node - img_node_name = f"IMG_{material_name}_{channel}" - img = ET.SubElement(root, "tiledimage", name=img_node_name, type="color3") - ET.SubElement(img, "input", name="file", type="filename", value=png_filename) - - # Connect texture to shader input - ET.SubElement(sr, "input", name=mtlx_input, type="color3", nodename=img_node_name) - - # Surface material - mat = ET.SubElement(root, "surfacematerial", name=material_name, type="material") - ET.SubElement( - mat, "input", name="surfaceshader", type="surfaceshader", nodename=f"SR_{material_name}" - ) - - # Write .mtlx file - tree = ET.ElementTree(root) - ET.indent(tree, space=" ") - mtlx_path = out / f"{material_name}.mtlx" - tree.write(mtlx_path, encoding="unicode", xml_declaration=True) - - return mtlx_path diff --git a/src/pymat/vis/_vendor_client.py b/src/pymat/vis/_vendor_client.py deleted file mode 100644 index 45713e2..0000000 --- a/src/pymat/vis/_vendor_client.py +++ /dev/null @@ -1,444 +0,0 @@ -#!/usr/bin/env python3 -"""mat-vis reference client — pure Python, zero dependencies. - -Fetches PBR textures from mat-vis GitHub Releases via HTTP range reads. -Uses only urllib (stdlib). No pyarrow, no binary deps. - -Usage as library: - from mat_vis_client import MatVisClient - client = MatVisClient() - png_bytes = client.fetch_texture("ambientcg", "Rock064", "color", tier="1k") - - # Search by category and scalar ranges - results = client.search("metal", roughness_range=(0.2, 0.6)) - - # Bulk prefetch all materials for offline use - client.prefetch("ambientcg", tier="1k") - -Usage as CLI: - python mat_vis_client.py list # list sources × tiers - python mat_vis_client.py materials ambientcg 1k # list materials - python mat_vis_client.py fetch ambientcg Rock064 color 1k # fetch PNG → stdout - python mat_vis_client.py fetch ambientcg Rock064 color 1k -o rock.png - python mat_vis_client.py search metal --roughness 0.2:0.6 # search materials - python mat_vis_client.py prefetch ambientcg 1k # bulk download -""" - -from __future__ import annotations - -import json -import os -import sys -import urllib.request -from pathlib import Path - -REPO = "MorePET/mat-vis" -GITHUB_RELEASES = f"https://github.com/{REPO}/releases" -GITHUB_RAW = f"https://raw.githubusercontent.com/{REPO}" -LATEST_MANIFEST_URL = f"{GITHUB_RELEASES}/latest/download/release-manifest.json" -DEFAULT_CACHE_DIR = Path(os.environ.get("MAT_VIS_CACHE", Path.home() / ".cache" / "mat-vis")) -USER_AGENT = "mat-vis-client/0.2 (Python)" - -# Valid categories per index-schema.json -CATEGORIES = frozenset( - [ - "metal", - "wood", - "stone", - "fabric", - "plastic", - "concrete", - "ceramic", - "glass", - "organic", - "other", - ] -) - - -def _get(url: str, headers: dict | None = None) -> bytes: - """HTTP GET with User-Agent.""" - hdrs = {"User-Agent": USER_AGENT} - if headers: - hdrs.update(headers) - req = urllib.request.Request(url, headers=hdrs) - with urllib.request.urlopen(req, timeout=60) as resp: - return resp.read() - - -def _get_json(url: str) -> dict | list: - """Fetch and parse JSON.""" - return json.loads(_get(url)) - - -def _in_range(value: float | None, lo: float, hi: float) -> bool: - """Check if a value falls within [lo, hi]. None values never match.""" - if value is None: - return False - return lo <= value <= hi - - -class MatVisClient: - """Lightweight client for mat-vis texture data.""" - - def __init__( - self, - *, - manifest_url: str | None = None, - cache_dir: Path | None = None, - tag: str | None = None, - ): - self._cache_dir = cache_dir or DEFAULT_CACHE_DIR - self._manifest: dict | None = None - self._rowmaps: dict[str, dict] = {} - self._indexes: dict[str, list[dict]] = {} - self._tag = tag - - if manifest_url: - self._manifest_url = manifest_url - elif tag: - self._manifest_url = f"{GITHUB_RELEASES}/download/{tag}/release-manifest.json" - else: - self._manifest_url = LATEST_MANIFEST_URL - - @property - def manifest(self) -> dict: - """Fetch and cache the release manifest.""" - if self._manifest is None: - cache_path = self._cache_dir / ".manifest.json" - if cache_path.exists(): - self._manifest = json.loads(cache_path.read_text()) - else: - self._manifest = _get_json(self._manifest_url) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(json.dumps(self._manifest, indent=2)) - return self._manifest - - def sources(self, tier: str = "1k") -> list[str]: - """List available sources for a tier.""" - tier_data = self.manifest.get("tiers", {}).get(tier, {}) - return list(tier_data.get("sources", {}).keys()) - - def tiers(self) -> list[str]: - """List available tiers.""" - return list(self.manifest.get("tiers", {}).keys()) - - def rowmap(self, source: str, tier: str, category: str | None = None) -> dict: - """Fetch and cache rowmaps. Merges partitioned rowmaps into one.""" - key = f"{source}-{tier}-{category or 'all'}" - if key not in self._rowmaps: - tier_data = self.manifest["tiers"][tier] - base_url = tier_data["base_url"] - src_data = tier_data["sources"][source] - - rowmap_files = src_data.get("rowmap_files", []) - if not rowmap_files: - rowmap_file = src_data.get("rowmap_file", f"{source}-{tier}-rowmap.json") - rowmap_files = [rowmap_file] - - if category: - rowmap_files = [f for f in rowmap_files if category in f] or rowmap_files[:1] - - # Fetch all partition rowmaps and merge materials - merged: dict = {"materials": {}} - for rmf in rowmap_files: - cache_path = self._cache_dir / ".rowmaps" / rmf - if cache_path.exists(): - rm = json.loads(cache_path.read_text()) - else: - url = base_url + rmf - rm = _get_json(url) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(json.dumps(rm, indent=2)) - - # Each partitioned rowmap has its own parquet_file - pq_file = rm.get("parquet_file", "") - for mid, channels in rm.get("materials", {}).items(): - # Tag each channel with its parquet file for range reads - for ch_data in channels.values(): - ch_data["parquet_file"] = pq_file - merged["materials"][mid] = channels - - # Keep metadata from last rowmap (they're all the same except materials) - for k in ("version", "release_tag", "source", "tier"): - if k in rm: - merged[k] = rm[k] - - self._rowmaps[key] = merged - - return self._rowmaps[key] - - def materials(self, source: str, tier: str) -> list[str]: - """List material IDs available for a source × tier.""" - rm = self.rowmap(source, tier) - return sorted(rm.get("materials", {}).keys()) - - def channels(self, source: str, material_id: str, tier: str) -> list[str]: - """List channels available for a material.""" - rm = self.rowmap(source, tier) - mat = rm.get("materials", {}).get(material_id, {}) - return sorted(mat.keys()) - - # ── Index & search ────────────────────────────────────────── - - def _index_url(self, source: str) -> str: - """Build the URL for a source's index JSON.""" - ref = self._tag or "main" - return f"{GITHUB_RAW}/{ref}/index/{source}.json" - - def index(self, source: str) -> list[dict]: - """Fetch and cache the material index for a source. - - Returns a list of material entries per index-schema.json. - """ - if source not in self._indexes: - cache_path = self._cache_dir / ".indexes" / f"{source}.json" - if cache_path.exists(): - self._indexes[source] = json.loads(cache_path.read_text()) - else: - url = self._index_url(source) - self._indexes[source] = _get_json(url) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(json.dumps(self._indexes[source], indent=2)) - return self._indexes[source] - - def search( - self, - category: str | None = None, - *, - roughness_range: tuple[float, float] | None = None, - metalness_range: tuple[float, float] | None = None, - source: str | None = None, - tier: str = "1k", - ) -> list[dict]: - """Search materials by category and scalar ranges. - - Fetches index JSON for the given source (or all sources for the - tier) and filters locally. Returns matching index entries. - - Args: - category: Filter by material category (e.g. "metal", "wood"). - roughness_range: (min, max) roughness filter, inclusive. - metalness_range: (min, max) metalness filter, inclusive. - source: Limit search to one source. If None, searches all - sources available for the given tier. - tier: Only return materials that have this tier available. - """ - if category and category not in CATEGORIES: - raise ValueError( - f"Unknown category {category!r}. Valid: {', '.join(sorted(CATEGORIES))}" - ) - - sources = [source] if source else self.sources(tier) - results: list[dict] = [] - - for src in sources: - for entry in self.index(src): - if category and entry.get("category") != category: - continue - if roughness_range and not _in_range(entry.get("roughness"), *roughness_range): - continue - if metalness_range and not _in_range(entry.get("metalness"), *metalness_range): - continue - if tier not in entry.get("available_tiers", []): - continue - results.append(entry) - - return results - - # ── Bulk operations ───────────────────────────────────────── - - def fetch_all_textures( - self, - source: str, - material_id: str, - tier: str = "1k", - ) -> dict[str, bytes]: - """Fetch all texture channels for a material. - - Returns a dict mapping channel name to PNG bytes. - """ - chs = self.channels(source, material_id, tier) - return {ch: self.fetch_texture(source, material_id, ch, tier) for ch in chs} - - def prefetch( - self, - source: str, - tier: str = "1k", - *, - on_progress: callable | None = None, - ) -> int: - """Bulk download all materials for a source + tier to cache. - - Args: - source: Source name (e.g. "ambientcg"). - tier: Resolution tier (default "1k"). - on_progress: Optional callback(material_id, index, total). - - Returns the number of materials fetched. - """ - mat_ids = self.materials(source, tier) - total = len(mat_ids) - - for i, mid in enumerate(mat_ids): - self.fetch_all_textures(source, mid, tier) - if on_progress: - on_progress(mid, i + 1, total) - - return total - - def rowmap_entry( - self, - source: str, - material_id: str, - tier: str = "1k", - ) -> dict[str, dict]: - """Get raw rowmap offsets for a material (for DIY consumers). - - Returns a dict of channel -> {offset, length, parquet_file}. - """ - rm = self.rowmap(source, tier) - mat = rm["materials"][material_id] - parquet_file = rm["parquet_file"] - return { - ch: {"offset": info["offset"], "length": info["length"], "parquet_file": parquet_file} - for ch, info in mat.items() - } - - def fetch_texture( - self, - source: str, - material_id: str, - channel: str, - tier: str = "1k", - ) -> bytes: - """Fetch a single texture PNG via HTTP range read. - - Returns raw PNG bytes. Caches locally. - """ - # Check cache first - cache_path = self._cache_dir / source / tier / material_id / f"{channel}.png" - if cache_path.exists(): - return cache_path.read_bytes() - - # Find in rowmap - rm = self.rowmap(source, tier) - mat = rm["materials"][material_id] - rng = mat[channel] - offset = rng["offset"] - length = rng["length"] - - # Find parquet URL (per-partition from merged rowmap) - tier_data = self.manifest["tiers"][tier] - base_url = tier_data["base_url"] - parquet_file = rng.get("parquet_file") or rm.get("parquet_file", "") - url = base_url + parquet_file - - # HTTP range read - range_header = f"bytes={offset}-{offset + length - 1}" - data = _get(url, headers={"Range": range_header}) - - # Verify PNG - if data[:4] != b"\x89PNG": - raise ValueError(f"Expected PNG, got {data[:4]!r}") - - # Cache - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_bytes(data) - - return data - - -# ── CLI ───────────────────────────────────────────────────────── - - -def _parse_range(s: str) -> tuple[float, float]: - """Parse 'lo:hi' into a (lo, hi) tuple.""" - parts = s.split(":") - if len(parts) != 2: - raise ValueError(f"Expected lo:hi, got {s!r}") - return float(parts[0]), float(parts[1]) - - -def main(): - import argparse - - parser = argparse.ArgumentParser(prog="mat-vis-client", description="mat-vis texture client") - parser.add_argument("--tag", help="Release tag (default: latest)") - sub = parser.add_subparsers(dest="cmd", required=True) - - sub.add_parser("list", help="List sources x tiers") - - p_mat = sub.add_parser("materials", help="List materials for a source x tier") - p_mat.add_argument("source") - p_mat.add_argument("tier", nargs="?", default="1k") - - p_fetch = sub.add_parser("fetch", help="Fetch a texture PNG") - p_fetch.add_argument("source") - p_fetch.add_argument("material") - p_fetch.add_argument("channel") - p_fetch.add_argument("tier", nargs="?", default="1k") - p_fetch.add_argument("-o", "--output", help="Output file (default: stdout)") - - p_search = sub.add_parser("search", help="Search materials by category / scalars") - p_search.add_argument("category", nargs="?", help="Category filter (e.g. metal, wood)") - p_search.add_argument("--source", help="Limit to one source") - p_search.add_argument("--tier", default="1k") - p_search.add_argument("--roughness", help="Roughness range as lo:hi") - p_search.add_argument("--metalness", help="Metalness range as lo:hi") - - p_prefetch = sub.add_parser("prefetch", help="Bulk download all materials for source x tier") - p_prefetch.add_argument("source") - p_prefetch.add_argument("tier", nargs="?", default="1k") - - args = parser.parse_args() - client = MatVisClient(tag=args.tag) - - if args.cmd == "list": - for tier in client.tiers(): - sources = client.sources(tier) - print(f"{tier}: {', '.join(sources)}") - - elif args.cmd == "materials": - for mid in client.materials(args.source, args.tier): - print(mid) - - elif args.cmd == "fetch": - data = client.fetch_texture(args.source, args.material, args.channel, args.tier) - if args.output: - Path(args.output).write_bytes(data) - print(f"Wrote {args.output} ({len(data):,} bytes)", file=sys.stderr) - else: - sys.stdout.buffer.write(data) - - elif args.cmd == "search": - roughness = _parse_range(args.roughness) if args.roughness else None - metalness = _parse_range(args.metalness) if args.metalness else None - results = client.search( - args.category, - roughness_range=roughness, - metalness_range=metalness, - source=args.source, - tier=args.tier, - ) - for entry in results: - scalars = [] - if entry.get("roughness") is not None: - scalars.append(f"R={entry['roughness']:.2f}") - if entry.get("metalness") is not None: - scalars.append(f"M={entry['metalness']:.2f}") - scalar_str = f" ({', '.join(scalars)})" if scalars else "" - print(f"{entry['source']}/{entry['id']} [{entry.get('category', '?')}]{scalar_str}") - print(f"\n{len(results)} result(s)", file=sys.stderr) - - elif args.cmd == "prefetch": - - def _progress(mid: str, i: int, total: int) -> None: - print(f"[{i}/{total}] {mid}", file=sys.stderr) - - n = client.prefetch(args.source, args.tier, on_progress=_progress) - print(f"Prefetched {n} materials", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index f1d4fd9..a2dcfde 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -3,9 +3,9 @@ generic adapter functions. The actual format logic (Three.js field names, glTF schema, -MaterialX XML) lives in _vendor_adapters.py (shipped by mat-vis). -These wrappers extract scalars + textures from a Material and -pass them through. +MaterialX XML) lives in mat_vis_client.adapters (installed from +mat-vis-client package). These wrappers extract scalars + textures +from a Material and pass them through. from pymat.vis.adapters import to_threejs, to_gltf, export_mtlx result = to_threejs(material) @@ -16,9 +16,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from pymat.vis._vendor_adapters import export_mtlx as _export_mtlx -from pymat.vis._vendor_adapters import to_gltf as _to_gltf -from pymat.vis._vendor_adapters import to_threejs as _to_threejs +from mat_vis_client.adapters import export_mtlx as _export_mtlx +from mat_vis_client.adapters import to_gltf as _to_gltf +from mat_vis_client.adapters import to_threejs as _to_threejs if TYPE_CHECKING: from pymat.core import _MaterialInternal as Material diff --git a/tests/test_vis.py b/tests/test_vis.py index 2c4dc1c..cacfa4c 100644 --- a/tests/test_vis.py +++ b/tests/test_vis.py @@ -117,7 +117,7 @@ def mock_fetch(source, material_id, *, tier="1k", **kw): called["material_id"] = material_id return {"color": b"\x89PNG_mock"} - monkeypatch.setattr("pymat.vis._client.fetch", mock_fetch) + monkeypatch.setattr("mat_vis_client.fetch", mock_fetch) v = Vis(source_id="ambientcg/Metal032") textures = v.textures @@ -166,7 +166,7 @@ def test_no_texture_no_scalar(self): class TestDiscover: def test_discover_returns_candidates(self, monkeypatch): - from pymat.vis import _client + import mat_vis_client as _client mock_results = [ {"id": "Metal032", "source": "ambientcg", "category": "metal", "score": 0.1}, @@ -181,7 +181,7 @@ def test_discover_returns_candidates(self, monkeypatch): assert v.source_id is None # not set without auto_set def test_discover_auto_set(self, monkeypatch): - from pymat.vis import _client + import mat_vis_client as _client mock_results = [ {"id": "Metal032", "source": "ambientcg", "category": "metal", "score": 0.1}, @@ -193,7 +193,7 @@ def test_discover_auto_set(self, monkeypatch): assert v.source_id == "ambientcg/Metal032" def test_discover_no_results(self, monkeypatch): - from pymat.vis import _client + import mat_vis_client as _client monkeypatch.setattr(_client, "search", lambda **kw: []) @@ -267,7 +267,7 @@ def test_get_manifest_returns_dict(self): def test_search_with_mock(self, monkeypatch): """Search against a mock client (no network).""" from pymat import vis - from pymat.vis import _client + import mat_vis_client as _client mock_results = [ {"id": "Metal001", "source": "ambientcg", "category": "metal", "roughness": 0.3, "metalness": 1.0}, @@ -289,7 +289,7 @@ def search(self, **kw): return mock_results def test_rowmap_entry_missing_material_raises(self, monkeypatch): from pymat import vis - from pymat.vis import _client + import mat_vis_client as _client class MockClient: def __init__(self, **kw): pass From 0f1148f40d70cdaac3333d53c1405888e3270626 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 15:50:16 +0200 Subject: [PATCH 19/32] feat: uncertainties as core dep + TOML range support (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uncertainties>=3.2 added to core dependencies (~59 KB, pure Python) - Loader accepts composition values as: - plain float: 0.68 - {nominal, stddev}: {nominal = 0.4, stddev = 0.1} → ufloat - {min, max}: {min = 0.2, max = 0.6} → ufloat(midpoint, half-range) - {nominal, min, max}: explicit nominal + range - Aluminum 6063 added with grade-spec composition ranges as proof-of-concept (Si, Mg, Fe have ranges; Al is balance) - Existing plain-float compositions unchanged - test_loader: handle ufloat in composition sum check - 14 new tests for parsing, propagation, material integration - 147 passed, 26 skipped Closes #33. --- pyproject.toml | 1 + src/pymat/data/metals.toml | 26 +++++++++ src/pymat/loader.py | 55 +++++++++++++++++- tests/test_loader.py | 4 +- tests/test_uncertainties.py | 110 ++++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/test_uncertainties.py diff --git a/pyproject.toml b/pyproject.toml index 8e06d0d..861790d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ dependencies = [ "pint>=0.20", "periodictable>=1.6.0", + "uncertainties>=3.2", "mat-vis-client>=2026.4.0", "tomli>=1.0.0; python_version == '3.10'", ] diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index fb863c9..e99a507 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -179,6 +179,32 @@ base_color = [0.2, 0.2, 0.2, 1.0] roughness = 0.5 transmission = 0.0 +# Aluminum 6063 (extrusion alloy — composition with spec ranges) +[aluminum.a6063] +name = "Aluminum 6063" +grade = "6063" + +# Composition with grade-spec ranges (per AZoM / ASM). +# Nominal values are typical mid-range; ranges are the spec window. +[aluminum.a6063.composition] +Si = {nominal = 0.004, min = 0.002, max = 0.006} +Mg = {nominal = 0.007, min = 0.0045, max = 0.009} +Fe = {nominal = 0.002, max = 0.0035} +Cu = 0.001 +Mn = 0.001 +Zn = 0.001 +Ti = 0.001 +Cr = 0.001 +Al = 0.982 + +[aluminum.a6063.mechanical] +density_value = 2.69 +density_unit = "g/cm^3" +yield_strength_value = 48 +yield_strength_unit = "MPa" +tensile_strength_value = 130 +tensile_strength_unit = "MPa" + # Aluminum 7075-T6 (aerospace) [aluminum.a7075] name = "Aluminum 7075" diff --git a/src/pymat/loader.py b/src/pymat/loader.py index 946baf9..2b1ac5b 100644 --- a/src/pymat/loader.py +++ b/src/pymat/loader.py @@ -19,6 +19,8 @@ else: import tomli as tomllib +from uncertainties import ufloat + if TYPE_CHECKING: from .core import Material @@ -32,6 +34,57 @@ logger = logging.getLogger(__name__) +def _parse_value(val: Any) -> float: + """Parse a scalar value that may be a plain float or a range/uncertainty dict. + + Accepted forms: + 0.4 → ufloat(0.4, 0) + {nominal = 0.4, stddev = 0.1} → ufloat(0.4, 0.1) + {min = 0.2, max = 0.6} → ufloat(0.4, 0.2) (midpoint ± half-range) + {nominal = 0.4, min = 0.2, max = 0.6} → ufloat(0.4, 0.2) + + Returns a ufloat when uncertainty info is present, plain float otherwise. + """ + if isinstance(val, (int, float)): + return float(val) + + if isinstance(val, dict): + nominal = val.get("nominal") + stddev = val.get("stddev") + lo = val.get("min") + hi = val.get("max") + + if nominal is None and lo is not None and hi is not None: + nominal = (lo + hi) / 2.0 + + if nominal is None: + raise ValueError(f"Cannot parse value: {val}") + + if stddev is not None: + return ufloat(nominal, stddev) + elif lo is not None and hi is not None: + return ufloat(nominal, (hi - lo) / 2.0) + else: + return float(nominal) + + return float(val) + + +def _parse_composition(comp: Any) -> dict[str, Any] | None: + """Parse a composition dict, handling range/uncertainty values. + + TOML examples: + composition = {Fe = 0.68, Cr = 0.18} # plain + composition = {Si = {min = 0.2, max = 0.6}} # range + composition = {Fe = {nominal = 0.68, stddev = 0.02}} # uncertainty + """ + if comp is None: + return None + if not isinstance(comp, dict): + return comp + return {el: _parse_value(val) for el, val in comp.items()} + + def _build_properties_from_dict( data: Dict[str, Any], parent_props: Optional[AllProperties] = None ) -> AllProperties: @@ -165,7 +218,7 @@ def _resolve_material_node( # Extract material-level attributes name = data.get("name", key) formula = data.get("formula") - composition = data.get("composition") + composition = _parse_composition(data.get("composition")) grade = data.get("grade") temper = data.get("temper") treatment = data.get("treatment") diff --git a/tests/test_loader.py b/tests/test_loader.py index 66dc23d..244781a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -201,7 +201,9 @@ def test_alloy_compositions_sum_to_one(self): for name, mat in materials.items(): if mat.composition: total = sum(mat.composition.values()) - assert abs(total - 1.0) < 0.02, ( + # Handle ufloat values (uncertainties package) + nominal = getattr(total, "nominal_value", total) + assert abs(nominal - 1.0) < 0.02, ( f"{name}: composition sums to {total}, expected ~1.0" ) diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py new file mode 100644 index 0000000..111c45e --- /dev/null +++ b/tests/test_uncertainties.py @@ -0,0 +1,110 @@ +"""Tests for uncertainty/range support in composition and scalar values.""" + +from __future__ import annotations + +from uncertainties import UFloat, ufloat + +from pymat.loader import _parse_value, _parse_composition + + +class TestParseValue: + def test_plain_float(self): + assert _parse_value(0.5) == 0.5 + assert isinstance(_parse_value(0.5), float) + + def test_plain_int(self): + assert _parse_value(1) == 1.0 + + def test_dict_with_stddev(self): + v = _parse_value({"nominal": 0.4, "stddev": 0.1}) + assert isinstance(v, UFloat) + assert v.nominal_value == 0.4 + assert v.std_dev == 0.1 + + def test_dict_with_range(self): + v = _parse_value({"min": 0.2, "max": 0.6}) + assert isinstance(v, UFloat) + assert v.nominal_value == 0.4 # midpoint + assert abs(v.std_dev - 0.2) < 1e-10 # half-range + + def test_dict_with_nominal_and_range(self): + v = _parse_value({"nominal": 0.35, "min": 0.2, "max": 0.6}) + assert isinstance(v, UFloat) + assert v.nominal_value == 0.35 # explicit nominal, not midpoint + assert abs(v.std_dev - 0.2) < 1e-10 + + def test_dict_nominal_only(self): + v = _parse_value({"nominal": 0.5}) + assert v == 0.5 + assert isinstance(v, float) # no uncertainty info → plain float + + def test_dict_max_only(self): + # {nominal = 0.05, max = 0.1} — common for trace elements + v = _parse_value({"nominal": 0.05, "max": 0.1}) + assert v == 0.05 # no min → no range → plain float + + +class TestParseComposition: + def test_none(self): + assert _parse_composition(None) is None + + def test_plain_dict(self): + comp = _parse_composition({"Fe": 0.68, "Cr": 0.18}) + assert comp["Fe"] == 0.68 + assert isinstance(comp["Fe"], float) + + def test_mixed_dict(self): + comp = _parse_composition({ + "Fe": 0.68, + "Si": {"min": 0.2, "max": 0.6}, + "Cr": {"nominal": 0.18, "stddev": 0.02}, + }) + assert isinstance(comp["Fe"], float) + assert isinstance(comp["Si"], UFloat) + assert isinstance(comp["Cr"], UFloat) + assert comp["Si"].nominal_value == 0.4 + assert comp["Cr"].std_dev == 0.02 + + +class TestUncertaintyPropagation: + def test_ufloat_sum(self): + """Summing ufloats propagates uncertainty correctly.""" + a = ufloat(0.4, 0.1) + b = ufloat(0.6, 0.2) + total = a + b + assert total.nominal_value == 1.0 + # Uncertainty adds in quadrature + assert abs(total.std_dev - (0.1**2 + 0.2**2) ** 0.5) < 1e-10 + + def test_ufloat_mixed_with_float(self): + """ufloat + plain float works.""" + a = ufloat(0.4, 0.1) + b = 0.5 + total = a + b + assert total.nominal_value == 0.9 + assert total.std_dev == 0.1 # float adds zero uncertainty + + +class TestMaterialWithRanges: + def test_a6063_has_ufloat_composition(self): + from pymat import aluminum + + a6063 = aluminum.a6063 + assert a6063.composition is not None + + # Si has a range + si = a6063.composition["Si"] + assert isinstance(si, UFloat) + assert si.nominal_value > 0 + + # Al is plain float (balance) + al = a6063.composition["Al"] + assert isinstance(al, float) + + def test_a6061_still_plain_floats(self): + """Existing plain-float compositions are unaffected.""" + from pymat import aluminum + + a6061 = aluminum.a6061 + for el, val in a6061.composition.items(): + assert isinstance(val, float), f"{el} should be float, got {type(val)}" From 60c318fc7fd1a65d34f17f1a0fcafde9beefbfea Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 15:54:04 +0200 Subject: [PATCH 20/32] feat: material catalog + CI workflows (#38, #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/catalog/ generated with 96 materials across 7 categories (thumbnails via CI when mat-vis tiers available) - .github/workflows/catalog.yml — regenerates on TOML data changes - .github/workflows/enrich-vis.yml — auto-proposes vis mappings on mat-vis release dispatch - vis.search() no longer filters by tier (index available_tiers may not reflect all actual tiers — search is for discovery, tier is a fetch-time concern) - Fixed ufloat sorting in catalog composition tables 147 passed, 26 skipped. Closes #38, #39. --- .github/workflows/catalog.yml | 30 +++++++++ .github/workflows/enrich-vis.yml | 45 +++++++++++++ docs/catalog/README.md | 13 ++++ docs/catalog/ceramics/README.md | 13 ++++ docs/catalog/ceramics/alumina-al995.md | 31 +++++++++ docs/catalog/ceramics/alumina-al999.md | 31 +++++++++ docs/catalog/ceramics/alumina.md | 31 +++++++++ docs/catalog/ceramics/glass-BK7.md | 33 ++++++++++ docs/catalog/ceramics/glass-borosilicate.md | 33 ++++++++++ docs/catalog/ceramics/glass-fused_silica.md | 34 ++++++++++ docs/catalog/ceramics/glass.md | 32 +++++++++ docs/catalog/ceramics/macor.md | 29 ++++++++ docs/catalog/ceramics/zirconia.md | 30 +++++++++ docs/catalog/electronics/README.md | 15 +++++ .../electronics/copper_pcb-gold_plated.md | 21 ++++++ docs/catalog/electronics/copper_pcb-oz1.md | 21 ++++++ docs/catalog/electronics/copper_pcb-oz2.md | 21 ++++++ docs/catalog/electronics/copper_pcb.md | 20 ++++++ docs/catalog/electronics/fr4.md | 22 +++++++ docs/catalog/electronics/kapton.md | 28 ++++++++ docs/catalog/electronics/rogers-r4350b.md | 21 ++++++ docs/catalog/electronics/rogers.md | 20 ++++++ docs/catalog/electronics/solder-SAC305.md | 27 ++++++++ docs/catalog/electronics/solder-Sn63Pb37.md | 27 ++++++++ docs/catalog/electronics/solder.md | 20 ++++++ docs/catalog/gases/README.md | 18 +++++ docs/catalog/gases/air.md | 23 +++++++ docs/catalog/gases/argon.md | 23 +++++++ docs/catalog/gases/co2-dry_ice.md | 23 +++++++ docs/catalog/gases/co2.md | 23 +++++++ docs/catalog/gases/helium-liquid.md | 23 +++++++ docs/catalog/gases/helium.md | 23 +++++++ docs/catalog/gases/hydrogen.md | 23 +++++++ docs/catalog/gases/methane.md | 23 +++++++ docs/catalog/gases/neon.md | 23 +++++++ docs/catalog/gases/nitrogen-liquid.md | 23 +++++++ docs/catalog/gases/nitrogen.md | 23 +++++++ docs/catalog/gases/oxygen.md | 23 +++++++ docs/catalog/gases/vacuum.md | 22 +++++++ docs/catalog/gases/xenon.md | 23 +++++++ docs/catalog/liquids/README.md | 10 +++ docs/catalog/liquids/glycerol.md | 31 +++++++++ docs/catalog/liquids/heavy_water.md | 31 +++++++++ docs/catalog/liquids/mineral_oil.md | 22 +++++++ docs/catalog/liquids/silicone_oil.md | 22 +++++++ docs/catalog/liquids/water-ice.md | 31 +++++++++ docs/catalog/liquids/water.md | 31 +++++++++ docs/catalog/metals/README.md | 23 +++++++ docs/catalog/metals/aluminum-a2024.md | 31 +++++++++ docs/catalog/metals/aluminum-a6061.md | 45 +++++++++++++ docs/catalog/metals/aluminum-a6063.md | 47 +++++++++++++ docs/catalog/metals/aluminum-a7075.md | 42 ++++++++++++ docs/catalog/metals/aluminum.md | 31 +++++++++ docs/catalog/metals/brass.md | 35 ++++++++++ docs/catalog/metals/copper-OFHC.md | 32 +++++++++ docs/catalog/metals/copper.md | 32 +++++++++ docs/catalog/metals/lead.md | 30 +++++++++ docs/catalog/metals/stainless-s17_4PH.md | 46 +++++++++++++ docs/catalog/metals/stainless-s303.md | 33 ++++++++++ docs/catalog/metals/stainless-s304.md | 44 +++++++++++++ docs/catalog/metals/stainless-s316L.md | 46 +++++++++++++ docs/catalog/metals/stainless.md | 49 ++++++++++++++ docs/catalog/metals/titanium-grade5.md | 39 +++++++++++ docs/catalog/metals/titanium.md | 31 +++++++++ docs/catalog/metals/tungsten-W90.md | 38 +++++++++++ docs/catalog/metals/tungsten-pure.md | 30 +++++++++ docs/catalog/metals/tungsten.md | 30 +++++++++ docs/catalog/plastics/README.md | 28 ++++++++ docs/catalog/plastics/abs.md | 28 ++++++++ docs/catalog/plastics/delrin.md | 31 +++++++++ docs/catalog/plastics/esr.md | 21 ++++++ docs/catalog/plastics/nylon.md | 29 ++++++++ docs/catalog/plastics/pc.md | 34 ++++++++++ docs/catalog/plastics/pctfe.md | 27 ++++++++ docs/catalog/plastics/pe-hdpe.md | 32 +++++++++ docs/catalog/plastics/pe-ldpe.md | 32 +++++++++ docs/catalog/plastics/pe-uhmwpe.md | 32 +++++++++ docs/catalog/plastics/pe.md | 31 +++++++++ docs/catalog/plastics/peek-CF30.md | 32 +++++++++ docs/catalog/plastics/peek-GF30.md | 32 +++++++++ docs/catalog/plastics/peek-unfilled.md | 32 +++++++++ docs/catalog/plastics/peek-victrex.md | 31 +++++++++ docs/catalog/plastics/peek.md | 32 +++++++++ docs/catalog/plastics/petg.md | 27 ++++++++ docs/catalog/plastics/pla.md | 29 ++++++++ docs/catalog/plastics/pmma.md | 34 ++++++++++ docs/catalog/plastics/ptfe-reflector.md | 29 ++++++++ docs/catalog/plastics/ptfe.md | 29 ++++++++ docs/catalog/plastics/torlon.md | 28 ++++++++ docs/catalog/plastics/tpu.md | 20 ++++++ docs/catalog/plastics/ultem.md | 30 +++++++++ docs/catalog/plastics/vespel.md | 28 ++++++++ docs/catalog/scintillators/README.md | 17 +++++ docs/catalog/scintillators/bgo.md | 29 ++++++++ docs/catalog/scintillators/csi-Na.md | 23 +++++++ docs/catalog/scintillators/csi-Tl.md | 23 +++++++ docs/catalog/scintillators/csi.md | 23 +++++++ docs/catalog/scintillators/labr3.md | 23 +++++++ docs/catalog/scintillators/lyso-Ce.md | 29 ++++++++ docs/catalog/scintillators/lyso.md | 29 ++++++++ docs/catalog/scintillators/nai-Tl.md | 23 +++++++ docs/catalog/scintillators/nai.md | 23 +++++++ .../scintillators/plastic_scint-BC400.md | 21 ++++++ .../scintillators/plastic_scint-EJ200.md | 21 ++++++ docs/catalog/scintillators/plastic_scint.md | 21 ++++++ docs/catalog/scintillators/pwo.md | 23 +++++++ scripts/generate_catalog.py | 12 +++- src/pymat/vis/__init__.py | 66 ++++++++++++++++++- 108 files changed, 3040 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/catalog.yml create mode 100644 .github/workflows/enrich-vis.yml create mode 100644 docs/catalog/README.md create mode 100644 docs/catalog/ceramics/README.md create mode 100644 docs/catalog/ceramics/alumina-al995.md create mode 100644 docs/catalog/ceramics/alumina-al999.md create mode 100644 docs/catalog/ceramics/alumina.md create mode 100644 docs/catalog/ceramics/glass-BK7.md create mode 100644 docs/catalog/ceramics/glass-borosilicate.md create mode 100644 docs/catalog/ceramics/glass-fused_silica.md create mode 100644 docs/catalog/ceramics/glass.md create mode 100644 docs/catalog/ceramics/macor.md create mode 100644 docs/catalog/ceramics/zirconia.md create mode 100644 docs/catalog/electronics/README.md create mode 100644 docs/catalog/electronics/copper_pcb-gold_plated.md create mode 100644 docs/catalog/electronics/copper_pcb-oz1.md create mode 100644 docs/catalog/electronics/copper_pcb-oz2.md create mode 100644 docs/catalog/electronics/copper_pcb.md create mode 100644 docs/catalog/electronics/fr4.md create mode 100644 docs/catalog/electronics/kapton.md create mode 100644 docs/catalog/electronics/rogers-r4350b.md create mode 100644 docs/catalog/electronics/rogers.md create mode 100644 docs/catalog/electronics/solder-SAC305.md create mode 100644 docs/catalog/electronics/solder-Sn63Pb37.md create mode 100644 docs/catalog/electronics/solder.md create mode 100644 docs/catalog/gases/README.md create mode 100644 docs/catalog/gases/air.md create mode 100644 docs/catalog/gases/argon.md create mode 100644 docs/catalog/gases/co2-dry_ice.md create mode 100644 docs/catalog/gases/co2.md create mode 100644 docs/catalog/gases/helium-liquid.md create mode 100644 docs/catalog/gases/helium.md create mode 100644 docs/catalog/gases/hydrogen.md create mode 100644 docs/catalog/gases/methane.md create mode 100644 docs/catalog/gases/neon.md create mode 100644 docs/catalog/gases/nitrogen-liquid.md create mode 100644 docs/catalog/gases/nitrogen.md create mode 100644 docs/catalog/gases/oxygen.md create mode 100644 docs/catalog/gases/vacuum.md create mode 100644 docs/catalog/gases/xenon.md create mode 100644 docs/catalog/liquids/README.md create mode 100644 docs/catalog/liquids/glycerol.md create mode 100644 docs/catalog/liquids/heavy_water.md create mode 100644 docs/catalog/liquids/mineral_oil.md create mode 100644 docs/catalog/liquids/silicone_oil.md create mode 100644 docs/catalog/liquids/water-ice.md create mode 100644 docs/catalog/liquids/water.md create mode 100644 docs/catalog/metals/README.md create mode 100644 docs/catalog/metals/aluminum-a2024.md create mode 100644 docs/catalog/metals/aluminum-a6061.md create mode 100644 docs/catalog/metals/aluminum-a6063.md create mode 100644 docs/catalog/metals/aluminum-a7075.md create mode 100644 docs/catalog/metals/aluminum.md create mode 100644 docs/catalog/metals/brass.md create mode 100644 docs/catalog/metals/copper-OFHC.md create mode 100644 docs/catalog/metals/copper.md create mode 100644 docs/catalog/metals/lead.md create mode 100644 docs/catalog/metals/stainless-s17_4PH.md create mode 100644 docs/catalog/metals/stainless-s303.md create mode 100644 docs/catalog/metals/stainless-s304.md create mode 100644 docs/catalog/metals/stainless-s316L.md create mode 100644 docs/catalog/metals/stainless.md create mode 100644 docs/catalog/metals/titanium-grade5.md create mode 100644 docs/catalog/metals/titanium.md create mode 100644 docs/catalog/metals/tungsten-W90.md create mode 100644 docs/catalog/metals/tungsten-pure.md create mode 100644 docs/catalog/metals/tungsten.md create mode 100644 docs/catalog/plastics/README.md create mode 100644 docs/catalog/plastics/abs.md create mode 100644 docs/catalog/plastics/delrin.md create mode 100644 docs/catalog/plastics/esr.md create mode 100644 docs/catalog/plastics/nylon.md create mode 100644 docs/catalog/plastics/pc.md create mode 100644 docs/catalog/plastics/pctfe.md create mode 100644 docs/catalog/plastics/pe-hdpe.md create mode 100644 docs/catalog/plastics/pe-ldpe.md create mode 100644 docs/catalog/plastics/pe-uhmwpe.md create mode 100644 docs/catalog/plastics/pe.md create mode 100644 docs/catalog/plastics/peek-CF30.md create mode 100644 docs/catalog/plastics/peek-GF30.md create mode 100644 docs/catalog/plastics/peek-unfilled.md create mode 100644 docs/catalog/plastics/peek-victrex.md create mode 100644 docs/catalog/plastics/peek.md create mode 100644 docs/catalog/plastics/petg.md create mode 100644 docs/catalog/plastics/pla.md create mode 100644 docs/catalog/plastics/pmma.md create mode 100644 docs/catalog/plastics/ptfe-reflector.md create mode 100644 docs/catalog/plastics/ptfe.md create mode 100644 docs/catalog/plastics/torlon.md create mode 100644 docs/catalog/plastics/tpu.md create mode 100644 docs/catalog/plastics/ultem.md create mode 100644 docs/catalog/plastics/vespel.md create mode 100644 docs/catalog/scintillators/README.md create mode 100644 docs/catalog/scintillators/bgo.md create mode 100644 docs/catalog/scintillators/csi-Na.md create mode 100644 docs/catalog/scintillators/csi-Tl.md create mode 100644 docs/catalog/scintillators/csi.md create mode 100644 docs/catalog/scintillators/labr3.md create mode 100644 docs/catalog/scintillators/lyso-Ce.md create mode 100644 docs/catalog/scintillators/lyso.md create mode 100644 docs/catalog/scintillators/nai-Tl.md create mode 100644 docs/catalog/scintillators/nai.md create mode 100644 docs/catalog/scintillators/plastic_scint-BC400.md create mode 100644 docs/catalog/scintillators/plastic_scint-EJ200.md create mode 100644 docs/catalog/scintillators/plastic_scint.md create mode 100644 docs/catalog/scintillators/pwo.md diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml new file mode 100644 index 0000000..bed83fc --- /dev/null +++ b/.github/workflows/catalog.yml @@ -0,0 +1,30 @@ +name: Update catalog + +on: + push: + branches: [dev] + paths: ["src/pymat/data/**"] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + catalog: + name: Regenerate material catalog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e '.[dev]' + - run: python scripts/generate_catalog.py --skip-thumbnails + - uses: peter-evans/create-pull-request@v7 + with: + title: "docs: regenerate material catalog" + commit-message: "docs: regenerate catalog from TOML data" + branch: docs/catalog-update + add-paths: docs/catalog/ + delete-branch: true diff --git a/.github/workflows/enrich-vis.yml b/.github/workflows/enrich-vis.yml new file mode 100644 index 0000000..2b3cd12 --- /dev/null +++ b/.github/workflows/enrich-vis.yml @@ -0,0 +1,45 @@ +name: Propose vis mappings + +on: + repository_dispatch: + types: [mat-vis-release] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + enrich: + name: Auto-propose vis mappings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e '.[dev]' + - run: python scripts/enrich_vis.py -o /tmp/proposed_vis.toml + - name: Check for proposals + id: check + run: | + if [ -s /tmp/proposed_vis.toml ]; then + echo "has_proposals=true" >> "$GITHUB_OUTPUT" + echo "Proposed vis mappings:" + cat /tmp/proposed_vis.toml + else + echo "has_proposals=false" >> "$GITHUB_OUTPUT" + echo "No new mappings to propose" + fi + - uses: peter-evans/create-pull-request@v7 + if: steps.check.outputs.has_proposals == 'true' + with: + title: "chore: update vis mappings from mat-vis" + commit-message: "chore: auto-proposed vis mappings" + branch: chore/enrich-vis + body: | + Auto-generated vis mapping proposals from mat-vis index. + Review each material's suggested appearance match before merging. + + Proposed mappings are in the commit diff. + delete-branch: true diff --git a/docs/catalog/README.md b/docs/catalog/README.md new file mode 100644 index 0000000..d5862ae --- /dev/null +++ b/docs/catalog/README.md @@ -0,0 +1,13 @@ +# Material Catalog + +Auto-generated from py-mat TOML data + mat-vis textures. + +| Category | Materials | +|---|---| +| [Metals](metals/README.md) | 19 | +| [Scintillators](scintillators/README.md) | 13 | +| [Plastics](plastics/README.md) | 24 | +| [Ceramics](ceramics/README.md) | 9 | +| [Electronics](electronics/README.md) | 11 | +| [Liquids](liquids/README.md) | 6 | +| [Gases](gases/README.md) | 14 | diff --git a/docs/catalog/ceramics/README.md b/docs/catalog/ceramics/README.md new file mode 100644 index 0000000..f35e058 --- /dev/null +++ b/docs/catalog/ceramics/README.md @@ -0,0 +1,13 @@ +# Ceramics + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [Alumina](alumina.md) | 3.95 g/cm³ | 0.5 | 0.0 | +| [99.5% Alumina](alumina-al995.md) | 3.89 g/cm³ | 0.5 | 0.0 | +| [99.9% Alumina](alumina-al999.md) | 3.96 g/cm³ | 0.5 | 0.0 | +| [Macor](macor.md) | 2.52 g/cm³ | 0.4 | 0.0 | +| [Zirconia](zirconia.md) | 6.0 g/cm³ | 0.4 | 0.0 | +| [Glass](glass.md) | 2.5 g/cm³ | 0.1 | 0.0 | +| [Borosilicate Glass](glass-borosilicate.md) | 2.5 g/cm³ | 0.1 | 0.0 | +| [Fused Silica](glass-fused_silica.md) | 2.2 g/cm³ | 0.1 | 0.0 | +| [BK7 Optical Glass](glass-BK7.md) | 2.5 g/cm³ | 0.1 | 0.0 | diff --git a/docs/catalog/ceramics/alumina-al995.md b/docs/catalog/ceramics/alumina-al995.md new file mode 100644 index 0000000..31bd5c4 --- /dev/null +++ b/docs/catalog/ceramics/alumina-al995.md @@ -0,0 +1,31 @@ +# 99.5% Alumina + +## Identity + +| Field | Value | +|---|---| +| Grade | 995 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 3.89 g/cm³ | +| Young's Modulus | 345 GPa | +| Yield Strength | 240 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2072 °C | +| Thermal Conductivity | 30 W/(m·K) | +| Specific Heat | 880 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.93, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/ceramics/alumina-al999.md b/docs/catalog/ceramics/alumina-al999.md new file mode 100644 index 0000000..465458a --- /dev/null +++ b/docs/catalog/ceramics/alumina-al999.md @@ -0,0 +1,31 @@ +# 99.9% Alumina + +## Identity + +| Field | Value | +|---|---| +| Grade | 999 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 3.96 g/cm³ | +| Young's Modulus | 345 GPa | +| Yield Strength | 240 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2072 °C | +| Thermal Conductivity | 30 W/(m·K) | +| Specific Heat | 880 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.93, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/ceramics/alumina.md b/docs/catalog/ceramics/alumina.md new file mode 100644 index 0000000..97e4b2a --- /dev/null +++ b/docs/catalog/ceramics/alumina.md @@ -0,0 +1,31 @@ +# Alumina + +## Identity + +| Field | Value | +|---|---| +| Formula | `Al2O3` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 3.95 g/cm³ | +| Young's Modulus | 345 GPa | +| Yield Strength | 240 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2072 °C | +| Thermal Conductivity | 30 W/(m·K) | +| Specific Heat | 880 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.93, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/ceramics/glass-BK7.md b/docs/catalog/ceramics/glass-BK7.md new file mode 100644 index 0000000..bf2d844 --- /dev/null +++ b/docs/catalog/ceramics/glass-BK7.md @@ -0,0 +1,33 @@ +# BK7 Optical Glass + +## Identity + +| Field | Value | +|---|---| +| Grade | BK7 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.5 g/cm³ | +| Young's Modulus | 70 GPa | +| Tensile Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1600 °C | +| Thermal Conductivity | 1.0 W/(m·K) | +| Specific Heat | 800 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.93, 0.95, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.52 | +| Transmission | 0.95 | diff --git a/docs/catalog/ceramics/glass-borosilicate.md b/docs/catalog/ceramics/glass-borosilicate.md new file mode 100644 index 0000000..e1041b2 --- /dev/null +++ b/docs/catalog/ceramics/glass-borosilicate.md @@ -0,0 +1,33 @@ +# Borosilicate Glass + +## Identity + +| Field | Value | +|---|---| +| Grade | borosilicate | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.5 g/cm³ | +| Young's Modulus | 70 GPa | +| Tensile Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1700 °C | +| Thermal Conductivity | 1.0 W/(m·K) | +| Specific Heat | 800 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.93, 0.95, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.52 | +| Transmission | 0.9 | diff --git a/docs/catalog/ceramics/glass-fused_silica.md b/docs/catalog/ceramics/glass-fused_silica.md new file mode 100644 index 0000000..9769a1b --- /dev/null +++ b/docs/catalog/ceramics/glass-fused_silica.md @@ -0,0 +1,34 @@ +# Fused Silica + +## Identity + +| Field | Value | +|---|---| +| Formula | `SiO2` | +| Grade | fused_silica | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.2 g/cm³ | +| Young's Modulus | 70 GPa | +| Tensile Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1600 °C | +| Thermal Conductivity | 1.0 W/(m·K) | +| Specific Heat | 800 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.93, 0.95, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.52 | +| Transmission | 0.9 | diff --git a/docs/catalog/ceramics/glass.md b/docs/catalog/ceramics/glass.md new file mode 100644 index 0000000..72580f4 --- /dev/null +++ b/docs/catalog/ceramics/glass.md @@ -0,0 +1,32 @@ +# Glass + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.5 g/cm³ | +| Young's Modulus | 70 GPa | +| Tensile Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1600 °C | +| Thermal Conductivity | 1.0 W/(m·K) | +| Specific Heat | 800 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.93, 0.95, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.52 | +| Transmission | 0.9 | diff --git a/docs/catalog/ceramics/macor.md b/docs/catalog/ceramics/macor.md new file mode 100644 index 0000000..325a5f6 --- /dev/null +++ b/docs/catalog/ceramics/macor.md @@ -0,0 +1,29 @@ +# Macor + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.52 g/cm³ | +| Young's Modulus | 66 GPa | +| Yield Strength | 60 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1000 °C | +| Thermal Conductivity | 1.46 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.98, 0.98, 0.96, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/ceramics/zirconia.md b/docs/catalog/ceramics/zirconia.md new file mode 100644 index 0000000..2ed2712 --- /dev/null +++ b/docs/catalog/ceramics/zirconia.md @@ -0,0 +1,30 @@ +# Zirconia + +## Identity + +| Field | Value | +|---|---| +| Formula | `ZrO2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 6.0 g/cm³ | +| Young's Modulus | 200 GPa | +| Yield Strength | 1000 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2715 °C | +| Thermal Conductivity | 2.0 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.88, 0.85, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/electronics/README.md b/docs/catalog/electronics/README.md new file mode 100644 index 0000000..a1fc1ff --- /dev/null +++ b/docs/catalog/electronics/README.md @@ -0,0 +1,15 @@ +# Electronics + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [FR4](fr4.md) | 1.86 g/cm³ | 0.7 | 0.0 | +| [Rogers RF Laminate](rogers.md) | 1.9 g/cm³ | 0.6 | 0.0 | +| [Rogers 4350B](rogers-r4350b.md) | 1.86 g/cm³ | 0.6 | 0.0 | +| [Kapton (Polyimide)](kapton.md) | 1.42 g/cm³ | 0.5 | 0.0 | +| [Copper (PCB)](copper_pcb.md) | 8.96 g/cm³ | 0.35 | 1.0 | +| [1 oz Copper (35 µm)](copper_pcb-oz1.md) | 8.96 g/cm³ | 0.35 | 1.0 | +| [2 oz Copper (70 µm)](copper_pcb-oz2.md) | 8.96 g/cm³ | 0.35 | 1.0 | +| [Gold Plated Copper (ENIG)](copper_pcb-gold_plated.md) | 8.96 g/cm³ | 0.2 | 1.0 | +| [Solder](solder.md) | 8.4 g/cm³ | 0.4 | 1.0 | +| [Sn63Pb37 (60% Tin / 40% Lead)](solder-Sn63Pb37.md) | 8.4 g/cm³ | 0.4 | 1.0 | +| [SAC305 (96.5% Tin / 3% Silver / 0.5% Copper)](solder-SAC305.md) | 7.3 g/cm³ | 0.4 | 1.0 | diff --git a/docs/catalog/electronics/copper_pcb-gold_plated.md b/docs/catalog/electronics/copper_pcb-gold_plated.md new file mode 100644 index 0000000..b446cd2 --- /dev/null +++ b/docs/catalog/electronics/copper_pcb-gold_plated.md @@ -0,0 +1,21 @@ +# Gold Plated Copper (ENIG) + +## Identity + +| Field | Value | +|---|---| +| Treatment | gold_plated | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.96 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(1.0, 0.84, 0.0, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.2 | diff --git a/docs/catalog/electronics/copper_pcb-oz1.md b/docs/catalog/electronics/copper_pcb-oz1.md new file mode 100644 index 0000000..9553834 --- /dev/null +++ b/docs/catalog/electronics/copper_pcb-oz1.md @@ -0,0 +1,21 @@ +# 1 oz Copper (35 µm) + +## Identity + +| Field | Value | +|---|---| +| Grade | 1oz | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.96 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.72, 0.45, 0.2, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.35 | diff --git a/docs/catalog/electronics/copper_pcb-oz2.md b/docs/catalog/electronics/copper_pcb-oz2.md new file mode 100644 index 0000000..eb09f35 --- /dev/null +++ b/docs/catalog/electronics/copper_pcb-oz2.md @@ -0,0 +1,21 @@ +# 2 oz Copper (70 µm) + +## Identity + +| Field | Value | +|---|---| +| Grade | 2oz | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.96 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.72, 0.45, 0.2, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.35 | diff --git a/docs/catalog/electronics/copper_pcb.md b/docs/catalog/electronics/copper_pcb.md new file mode 100644 index 0000000..bf8b36b --- /dev/null +++ b/docs/catalog/electronics/copper_pcb.md @@ -0,0 +1,20 @@ +# Copper (PCB) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.96 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.72, 0.45, 0.2, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.35 | diff --git a/docs/catalog/electronics/fr4.md b/docs/catalog/electronics/fr4.md new file mode 100644 index 0000000..66ca99c --- /dev/null +++ b/docs/catalog/electronics/fr4.md @@ -0,0 +1,22 @@ +# FR4 + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.86 g/cm³ | +| Young's Modulus | 19 GPa | +| Tensile Strength | 60 MPa | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.15, 0.15, 0.15, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.7 | diff --git a/docs/catalog/electronics/kapton.md b/docs/catalog/electronics/kapton.md new file mode 100644 index 0000000..502a4da --- /dev/null +++ b/docs/catalog/electronics/kapton.md @@ -0,0 +1,28 @@ +# Kapton (Polyimide) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.42 g/cm³ | +| Tensile Strength | 230 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 400 °C | +| Thermal Conductivity | 0.12 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.85, 0.4, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/electronics/rogers-r4350b.md b/docs/catalog/electronics/rogers-r4350b.md new file mode 100644 index 0000000..63bb0dd --- /dev/null +++ b/docs/catalog/electronics/rogers-r4350b.md @@ -0,0 +1,21 @@ +# Rogers 4350B + +## Identity + +| Field | Value | +|---|---| +| Grade | 4350B | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.86 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.2, 0.2, 0.2, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/electronics/rogers.md b/docs/catalog/electronics/rogers.md new file mode 100644 index 0000000..209f8e9 --- /dev/null +++ b/docs/catalog/electronics/rogers.md @@ -0,0 +1,20 @@ +# Rogers RF Laminate + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.9 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.2, 0.2, 0.2, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/electronics/solder-SAC305.md b/docs/catalog/electronics/solder-SAC305.md new file mode 100644 index 0000000..31db408 --- /dev/null +++ b/docs/catalog/electronics/solder-SAC305.md @@ -0,0 +1,27 @@ +# SAC305 (96.5% Tin / 3% Silver / 0.5% Copper) + +## Identity + +| Field | Value | +|---|---| +| Grade | SAC305 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 7.3 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 217 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.7, 0.7, 0.7, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/electronics/solder-Sn63Pb37.md b/docs/catalog/electronics/solder-Sn63Pb37.md new file mode 100644 index 0000000..f2b8561 --- /dev/null +++ b/docs/catalog/electronics/solder-Sn63Pb37.md @@ -0,0 +1,27 @@ +# Sn63Pb37 (60% Tin / 40% Lead) + +## Identity + +| Field | Value | +|---|---| +| Grade | Sn63Pb37 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.4 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 183 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.7, 0.7, 0.7, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/electronics/solder.md b/docs/catalog/electronics/solder.md new file mode 100644 index 0000000..29957e0 --- /dev/null +++ b/docs/catalog/electronics/solder.md @@ -0,0 +1,20 @@ +# Solder + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.4 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.7, 0.7, 0.7, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/gases/README.md b/docs/catalog/gases/README.md new file mode 100644 index 0000000..cf9c469 --- /dev/null +++ b/docs/catalog/gases/README.md @@ -0,0 +1,18 @@ +# Gases + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [Air](air.md) | 0.001204 g/cm³ | 0.0 | 0.0 | +| [Nitrogen](nitrogen.md) | 0.001165 g/cm³ | 0.0 | 0.0 | +| [Liquid Nitrogen](nitrogen-liquid.md) | 0.808 g/cm³ | 0.0 | 0.0 | +| [Oxygen](oxygen.md) | 0.001331 g/cm³ | 0.0 | 0.0 | +| [Argon](argon.md) | 0.001662 g/cm³ | 0.0 | 0.0 | +| [Carbon Dioxide](co2.md) | 0.001839 g/cm³ | 0.0 | 0.0 | +| [Dry Ice](co2-dry_ice.md) | 1.56 g/cm³ | 0.3 | 0.0 | +| [Helium](helium.md) | 0.000166 g/cm³ | 0.0 | 0.0 | +| [Liquid Helium](helium-liquid.md) | 0.125 g/cm³ | 0.0 | 0.0 | +| [Hydrogen](hydrogen.md) | 8.38e-05 g/cm³ | 0.0 | 0.0 | +| [Neon](neon.md) | 0.000839 g/cm³ | 0.0 | 0.0 | +| [Xenon](xenon.md) | 0.005458 g/cm³ | 0.0 | 0.0 | +| [Methane](methane.md) | 0.000668 g/cm³ | 0.0 | 0.0 | +| [Vacuum](vacuum.md) | — | 0.0 | 0.0 | diff --git a/docs/catalog/gases/air.md b/docs/catalog/gases/air.md new file mode 100644 index 0000000..0a09ed3 --- /dev/null +++ b/docs/catalog/gases/air.md @@ -0,0 +1,23 @@ +# Air + +## Identity + +| Field | Value | +|---|---| +| Formula | `N2O2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.001204 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000293 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/argon.md b/docs/catalog/gases/argon.md new file mode 100644 index 0000000..d79f564 --- /dev/null +++ b/docs/catalog/gases/argon.md @@ -0,0 +1,23 @@ +# Argon + +## Identity + +| Field | Value | +|---|---| +| Formula | `Ar` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.001662 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000281 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/co2-dry_ice.md b/docs/catalog/gases/co2-dry_ice.md new file mode 100644 index 0000000..24841fd --- /dev/null +++ b/docs/catalog/gases/co2-dry_ice.md @@ -0,0 +1,23 @@ +# Dry Ice + +## Identity + +| Field | Value | +|---|---| +| Treatment | solid | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.56 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 1.0, 0.7)` | +| Metallic | 0.0 | +| Roughness | 0.3 | +| IOR | 1.000449 | +| Transmission | 0.7 | diff --git a/docs/catalog/gases/co2.md b/docs/catalog/gases/co2.md new file mode 100644 index 0000000..c36e69f --- /dev/null +++ b/docs/catalog/gases/co2.md @@ -0,0 +1,23 @@ +# Carbon Dioxide + +## Identity + +| Field | Value | +|---|---| +| Formula | `CO2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.001839 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000449 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/helium-liquid.md b/docs/catalog/gases/helium-liquid.md new file mode 100644 index 0000000..2e51e00 --- /dev/null +++ b/docs/catalog/gases/helium-liquid.md @@ -0,0 +1,23 @@ +# Liquid Helium + +## Identity + +| Field | Value | +|---|---| +| Treatment | cryogenic | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.125 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.9, 0.95, 0.3)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000035 | +| Transmission | 0.95 | diff --git a/docs/catalog/gases/helium.md b/docs/catalog/gases/helium.md new file mode 100644 index 0000000..33f24e5 --- /dev/null +++ b/docs/catalog/gases/helium.md @@ -0,0 +1,23 @@ +# Helium + +## Identity + +| Field | Value | +|---|---| +| Formula | `He` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.000166 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.005)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000035 | +| Transmission | 0.995 | diff --git a/docs/catalog/gases/hydrogen.md b/docs/catalog/gases/hydrogen.md new file mode 100644 index 0000000..b82fef7 --- /dev/null +++ b/docs/catalog/gases/hydrogen.md @@ -0,0 +1,23 @@ +# Hydrogen + +## Identity + +| Field | Value | +|---|---| +| Formula | `H2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.38e-05 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.005)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000132 | +| Transmission | 0.995 | diff --git a/docs/catalog/gases/methane.md b/docs/catalog/gases/methane.md new file mode 100644 index 0000000..78c1a90 --- /dev/null +++ b/docs/catalog/gases/methane.md @@ -0,0 +1,23 @@ +# Methane + +## Identity + +| Field | Value | +|---|---| +| Formula | `CH4` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.000668 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000444 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/neon.md b/docs/catalog/gases/neon.md new file mode 100644 index 0000000..33bfecb --- /dev/null +++ b/docs/catalog/gases/neon.md @@ -0,0 +1,23 @@ +# Neon + +## Identity + +| Field | Value | +|---|---| +| Formula | `Ne` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.000839 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(1.0, 0.7, 0.3, 0.02)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000067 | +| Transmission | 0.98 | diff --git a/docs/catalog/gases/nitrogen-liquid.md b/docs/catalog/gases/nitrogen-liquid.md new file mode 100644 index 0000000..95ff529 --- /dev/null +++ b/docs/catalog/gases/nitrogen-liquid.md @@ -0,0 +1,23 @@ +# Liquid Nitrogen + +## Identity + +| Field | Value | +|---|---| +| Treatment | cryogenic | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.808 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.9, 0.95, 0.4)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000298 | +| Transmission | 0.9 | diff --git a/docs/catalog/gases/nitrogen.md b/docs/catalog/gases/nitrogen.md new file mode 100644 index 0000000..7461a26 --- /dev/null +++ b/docs/catalog/gases/nitrogen.md @@ -0,0 +1,23 @@ +# Nitrogen + +## Identity + +| Field | Value | +|---|---| +| Formula | `N2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.001165 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000298 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/oxygen.md b/docs/catalog/gases/oxygen.md new file mode 100644 index 0000000..83fdc0a --- /dev/null +++ b/docs/catalog/gases/oxygen.md @@ -0,0 +1,23 @@ +# Oxygen + +## Identity + +| Field | Value | +|---|---| +| Formula | `O2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.001331 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.01)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000271 | +| Transmission | 0.99 | diff --git a/docs/catalog/gases/vacuum.md b/docs/catalog/gases/vacuum.md new file mode 100644 index 0000000..463b502 --- /dev/null +++ b/docs/catalog/gases/vacuum.md @@ -0,0 +1,22 @@ +# Vacuum + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.0 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.0, 0.0, 0.0, 0.0)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.0 | +| Transmission | 1.0 | diff --git a/docs/catalog/gases/xenon.md b/docs/catalog/gases/xenon.md new file mode 100644 index 0000000..f716ba1 --- /dev/null +++ b/docs/catalog/gases/xenon.md @@ -0,0 +1,23 @@ +# Xenon + +## Identity + +| Field | Value | +|---|---| +| Formula | `Xe` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.005458 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.8, 0.85, 1.0, 0.02)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.000702 | +| Transmission | 0.98 | diff --git a/docs/catalog/liquids/README.md b/docs/catalog/liquids/README.md new file mode 100644 index 0000000..db14581 --- /dev/null +++ b/docs/catalog/liquids/README.md @@ -0,0 +1,10 @@ +# Liquids + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [Water](water.md) | 0.998 g/cm³ | 0.0 | 0.0 | +| [Ice](water-ice.md) | 0.917 g/cm³ | 0.1 | 0.0 | +| [Heavy Water (D2O)](heavy_water.md) | 1.107 g/cm³ | 0.0 | 0.0 | +| [Mineral Oil](mineral_oil.md) | 0.85 g/cm³ | 0.0 | 0.0 | +| [Glycerol](glycerol.md) | 1.261 g/cm³ | 0.0 | 0.0 | +| [Silicone Oil](silicone_oil.md) | 0.97 g/cm³ | 0.0 | 0.0 | diff --git a/docs/catalog/liquids/glycerol.md b/docs/catalog/liquids/glycerol.md new file mode 100644 index 0000000..0332039 --- /dev/null +++ b/docs/catalog/liquids/glycerol.md @@ -0,0 +1,31 @@ +# Glycerol + +## Identity + +| Field | Value | +|---|---| +| Formula | `C3H8O3` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.261 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 18 °C | +| Thermal Conductivity | 0.29 W/(m·K) | +| Specific Heat | 2430 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.9, 0.5)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.473 | +| Transmission | 0.9 | diff --git a/docs/catalog/liquids/heavy_water.md b/docs/catalog/liquids/heavy_water.md new file mode 100644 index 0000000..fe8c5c9 --- /dev/null +++ b/docs/catalog/liquids/heavy_water.md @@ -0,0 +1,31 @@ +# Heavy Water (D2O) + +## Identity + +| Field | Value | +|---|---| +| Formula | `D2O` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.107 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 3.82 °C | +| Thermal Conductivity | 0.595 W/(m·K) | +| Specific Heat | 4210 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.7, 0.85, 0.95, 0.3)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.328 | +| Transmission | 0.95 | diff --git a/docs/catalog/liquids/mineral_oil.md b/docs/catalog/liquids/mineral_oil.md new file mode 100644 index 0000000..cf57d2d --- /dev/null +++ b/docs/catalog/liquids/mineral_oil.md @@ -0,0 +1,22 @@ +# Mineral Oil + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.85 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.9, 0.7, 0.4)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.47 | +| Transmission | 0.9 | diff --git a/docs/catalog/liquids/silicone_oil.md b/docs/catalog/liquids/silicone_oil.md new file mode 100644 index 0000000..26bbb7a --- /dev/null +++ b/docs/catalog/liquids/silicone_oil.md @@ -0,0 +1,22 @@ +# Silicone Oil + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.97 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.9, 0.4)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.4 | +| Transmission | 0.9 | diff --git a/docs/catalog/liquids/water-ice.md b/docs/catalog/liquids/water-ice.md new file mode 100644 index 0000000..ccf2b07 --- /dev/null +++ b/docs/catalog/liquids/water-ice.md @@ -0,0 +1,31 @@ +# Ice + +## Identity + +| Field | Value | +|---|---| +| Treatment | frozen | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.917 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 0 °C | +| Thermal Conductivity | 2.22 W/(m·K) | +| Specific Heat | 2090 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.95, 1.0, 0.7)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.333 | +| Transmission | 0.8 | diff --git a/docs/catalog/liquids/water.md b/docs/catalog/liquids/water.md new file mode 100644 index 0000000..a1c1f8d --- /dev/null +++ b/docs/catalog/liquids/water.md @@ -0,0 +1,31 @@ +# Water + +## Identity + +| Field | Value | +|---|---| +| Formula | `H2O` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.998 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 0 °C | +| Thermal Conductivity | 0.598 W/(m·K) | +| Specific Heat | 4182 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.7, 0.85, 0.95, 0.3)` | +| Metallic | 0.0 | +| Roughness | 0.0 | +| IOR | 1.333 | +| Transmission | 0.95 | diff --git a/docs/catalog/metals/README.md b/docs/catalog/metals/README.md new file mode 100644 index 0000000..5c56673 --- /dev/null +++ b/docs/catalog/metals/README.md @@ -0,0 +1,23 @@ +# Metals + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [Stainless Steel](stainless.md) | 8.0 g/cm³ | 0.3 | 1.0 | +| [Stainless Steel 304](stainless-s304.md) | 8.0 g/cm³ | 0.3 | 1.0 | +| [Stainless Steel 316L](stainless-s316L.md) | 8.0 g/cm³ | 0.3 | 1.0 | +| [Stainless Steel 303](stainless-s303.md) | 8.0 g/cm³ | 0.3 | 1.0 | +| [Stainless Steel 17-4 PH](stainless-s17_4PH.md) | 7.8 g/cm³ | 0.3 | 1.0 | +| [Aluminum](aluminum.md) | 2.7 g/cm³ | 0.4 | 1.0 | +| [Aluminum 6061-T6](aluminum-a6061.md) | 2.7 g/cm³ | 0.4 | 1.0 | +| [Aluminum 6063](aluminum-a6063.md) | 2.69 g/cm³ | 0.4 | 1.0 | +| [Aluminum 7075](aluminum-a7075.md) | 2.81 g/cm³ | 0.4 | 1.0 | +| [Aluminum 2024](aluminum-a2024.md) | 2.78 g/cm³ | 0.4 | 1.0 | +| [Copper](copper.md) | 8.96 g/cm³ | 0.3 | 1.0 | +| [OFHC Copper (Oxygen-Free High Conductivity)](copper-OFHC.md) | 8.94 g/cm³ | 0.3 | 1.0 | +| [Tungsten](tungsten.md) | 19.3 g/cm³ | 0.4 | 1.0 | +| [Tungsten 99.95%](tungsten-pure.md) | 19.3 g/cm³ | 0.4 | 1.0 | +| [Tungsten Heavy Alloy (90% W)](tungsten-W90.md) | 17.0 g/cm³ | 0.4 | 1.0 | +| [Lead](lead.md) | 11.34 g/cm³ | 0.5 | 1.0 | +| [Titanium](titanium.md) | 4.51 g/cm³ | 0.3 | 1.0 | +| [Ti-6Al-4V (Grade 5)](titanium-grade5.md) | 4.43 g/cm³ | 0.3 | 1.0 | +| [Brass (Cu-Zn)](brass.md) | 8.5 g/cm³ | 0.25 | 1.0 | diff --git a/docs/catalog/metals/aluminum-a2024.md b/docs/catalog/metals/aluminum-a2024.md new file mode 100644 index 0000000..7591ec3 --- /dev/null +++ b/docs/catalog/metals/aluminum-a2024.md @@ -0,0 +1,31 @@ +# Aluminum 2024 + +## Identity + +| Field | Value | +|---|---| +| Grade | 2024 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.78 g/cm³ | +| Young's Modulus | 69 GPa | +| Poisson's Ratio | 0.33 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 660 °C | +| Thermal Conductivity | 235 W/(m·K) | +| Specific Heat | 900 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.88, 0.88, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/metals/aluminum-a6061.md b/docs/catalog/metals/aluminum-a6061.md new file mode 100644 index 0000000..fa36878 --- /dev/null +++ b/docs/catalog/metals/aluminum-a6061.md @@ -0,0 +1,45 @@ +# Aluminum 6061-T6 + +## Identity + +| Field | Value | +|---|---| +| Grade | 6061 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.7 g/cm³ | +| Young's Modulus | 69 GPa | +| Yield Strength | 276 MPa | +| Tensile Strength | 310 MPa | +| Poisson's Ratio | 0.33 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 660 °C | +| Thermal Conductivity | 235 W/(m·K) | +| Specific Heat | 900 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.88, 0.88, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | + +## Composition + +| Element | Fraction | +|---|---| +| Al | 0.972 | +| Mg | 0.01 | +| Si | 0.006 | +| Cu | 0.004 | +| Fe | 0.003 | +| Zn | 0.003 | +| Cr | 0.002 | diff --git a/docs/catalog/metals/aluminum-a6063.md b/docs/catalog/metals/aluminum-a6063.md new file mode 100644 index 0000000..7cf7f1d --- /dev/null +++ b/docs/catalog/metals/aluminum-a6063.md @@ -0,0 +1,47 @@ +# Aluminum 6063 + +## Identity + +| Field | Value | +|---|---| +| Grade | 6063 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.69 g/cm³ | +| Young's Modulus | 69 GPa | +| Yield Strength | 48 MPa | +| Tensile Strength | 130 MPa | +| Poisson's Ratio | 0.33 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 660 °C | +| Thermal Conductivity | 235 W/(m·K) | +| Specific Heat | 900 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.88, 0.88, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | + +## Composition + +| Element | Fraction | +|---|---| +| Al | 0.982 | +| Mg | 0.007 ± 0.00225 | +| Si | 0.004 ± 0.002 | +| Fe | 0.002 | +| Cu | 0.001 | +| Mn | 0.001 | +| Zn | 0.001 | +| Ti | 0.001 | +| Cr | 0.001 | diff --git a/docs/catalog/metals/aluminum-a7075.md b/docs/catalog/metals/aluminum-a7075.md new file mode 100644 index 0000000..77eef73 --- /dev/null +++ b/docs/catalog/metals/aluminum-a7075.md @@ -0,0 +1,42 @@ +# Aluminum 7075 + +## Identity + +| Field | Value | +|---|---| +| Grade | 7075 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.81 g/cm³ | +| Young's Modulus | 72 GPa | +| Poisson's Ratio | 0.33 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 660 °C | +| Thermal Conductivity | 235 W/(m·K) | +| Specific Heat | 900 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.88, 0.88, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | + +## Composition + +| Element | Fraction | +|---|---| +| Al | 0.9 | +| Zn | 0.056 | +| Mg | 0.025 | +| Cu | 0.016 | +| Cr | 0.002 | +| Fe | 0.001 | diff --git a/docs/catalog/metals/aluminum.md b/docs/catalog/metals/aluminum.md new file mode 100644 index 0000000..040b1ce --- /dev/null +++ b/docs/catalog/metals/aluminum.md @@ -0,0 +1,31 @@ +# Aluminum + +## Identity + +| Field | Value | +|---|---| +| Formula | `Al` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.7 g/cm³ | +| Young's Modulus | 69 GPa | +| Poisson's Ratio | 0.33 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 660 °C | +| Thermal Conductivity | 235 W/(m·K) | +| Specific Heat | 900 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.88, 0.88, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/metals/brass.md b/docs/catalog/metals/brass.md new file mode 100644 index 0000000..f5cc324 --- /dev/null +++ b/docs/catalog/metals/brass.md @@ -0,0 +1,35 @@ +# Brass (Cu-Zn) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.5 g/cm³ | +| Young's Modulus | 97 GPa | +| Yield Strength | 200 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 900 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.88, 0.78, 0.5, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.25 | + +## Composition + +| Element | Fraction | +|---|---| +| Cu | 0.65 | +| Zn | 0.35 | diff --git a/docs/catalog/metals/copper-OFHC.md b/docs/catalog/metals/copper-OFHC.md new file mode 100644 index 0000000..93776b2 --- /dev/null +++ b/docs/catalog/metals/copper-OFHC.md @@ -0,0 +1,32 @@ +# OFHC Copper (Oxygen-Free High Conductivity) + +## Identity + +| Field | Value | +|---|---| +| Grade | OFHC | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.94 g/cm³ | +| Young's Modulus | 110 GPa | +| Yield Strength | 40 MPa | +| Tensile Strength | 200 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1085 °C | +| Thermal Conductivity | 398 W/(m·K) | +| Specific Heat | 385 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.72, 0.45, 0.2, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | diff --git a/docs/catalog/metals/copper.md b/docs/catalog/metals/copper.md new file mode 100644 index 0000000..53b5007 --- /dev/null +++ b/docs/catalog/metals/copper.md @@ -0,0 +1,32 @@ +# Copper + +## Identity + +| Field | Value | +|---|---| +| Formula | `Cu` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.96 g/cm³ | +| Young's Modulus | 110 GPa | +| Yield Strength | 40 MPa | +| Tensile Strength | 200 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1085 °C | +| Thermal Conductivity | 398 W/(m·K) | +| Specific Heat | 385 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.72, 0.45, 0.2, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | diff --git a/docs/catalog/metals/lead.md b/docs/catalog/metals/lead.md new file mode 100644 index 0000000..e223788 --- /dev/null +++ b/docs/catalog/metals/lead.md @@ -0,0 +1,30 @@ +# Lead + +## Identity + +| Field | Value | +|---|---| +| Formula | `Pb` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 11.34 g/cm³ | +| Young's Modulus | 16 GPa | +| Yield Strength | 12 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 327 °C | +| Thermal Conductivity | 35.3 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.3, 0.3, 0.33, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/metals/stainless-s17_4PH.md b/docs/catalog/metals/stainless-s17_4PH.md new file mode 100644 index 0000000..76b72b4 --- /dev/null +++ b/docs/catalog/metals/stainless-s17_4PH.md @@ -0,0 +1,46 @@ +# Stainless Steel 17-4 PH + +## Identity + +| Field | Value | +|---|---| +| Grade | 17-4 PH | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 7.8 g/cm³ | +| Young's Modulus | 193 GPa | +| Yield Strength | 1170 MPa | +| Tensile Strength | 1310 MPa | +| Poisson's Ratio | 0.27 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1450 °C | +| Thermal Conductivity | 15.1 W/(m·K) | +| Specific Heat | 500 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.75, 0.75, 0.77, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | + +## Composition + +| Element | Fraction | +|---|---| +| Fe | 0.738 | +| Cr | 0.16 | +| Ni | 0.04 | +| Cu | 0.035 | +| Mn | 0.01 | +| Si | 0.01 | +| C | 0.004 | +| Nb | 0.003 | diff --git a/docs/catalog/metals/stainless-s303.md b/docs/catalog/metals/stainless-s303.md new file mode 100644 index 0000000..d70fc4b --- /dev/null +++ b/docs/catalog/metals/stainless-s303.md @@ -0,0 +1,33 @@ +# Stainless Steel 303 + +## Identity + +| Field | Value | +|---|---| +| Grade | 303 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.0 g/cm³ | +| Young's Modulus | 193 GPa | +| Yield Strength | 205 MPa | +| Tensile Strength | 515 MPa | +| Poisson's Ratio | 0.27 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1450 °C | +| Thermal Conductivity | 15.1 W/(m·K) | +| Specific Heat | 500 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.75, 0.75, 0.77, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | diff --git a/docs/catalog/metals/stainless-s304.md b/docs/catalog/metals/stainless-s304.md new file mode 100644 index 0000000..b1b399f --- /dev/null +++ b/docs/catalog/metals/stainless-s304.md @@ -0,0 +1,44 @@ +# Stainless Steel 304 + +## Identity + +| Field | Value | +|---|---| +| Grade | 304 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.0 g/cm³ | +| Young's Modulus | 193 GPa | +| Yield Strength | 170 MPa | +| Tensile Strength | 515 MPa | +| Poisson's Ratio | 0.27 | +| Hardness (Vickers) | 217 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1450 °C | +| Thermal Conductivity | 15.1 W/(m·K) | +| Specific Heat | 500 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.75, 0.75, 0.77, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | + +## Composition + +| Element | Fraction | +|---|---| +| Fe | 0.69 | +| Cr | 0.19 | +| Ni | 0.095 | +| Mn | 0.02 | +| Si | 0.005 | diff --git a/docs/catalog/metals/stainless-s316L.md b/docs/catalog/metals/stainless-s316L.md new file mode 100644 index 0000000..d95afd5 --- /dev/null +++ b/docs/catalog/metals/stainless-s316L.md @@ -0,0 +1,46 @@ +# Stainless Steel 316L + +## Identity + +| Field | Value | +|---|---| +| Grade | 316L | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.0 g/cm³ | +| Young's Modulus | 193 GPa | +| Yield Strength | 170 MPa | +| Tensile Strength | 485 MPa | +| Poisson's Ratio | 0.27 | +| Hardness (Vickers) | 217 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1450 °C | +| Thermal Conductivity | 15.1 W/(m·K) | +| Specific Heat | 500 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.75, 0.75, 0.77, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | + +## Composition + +| Element | Fraction | +|---|---| +| Fe | 0.65 | +| Cr | 0.17 | +| Ni | 0.12 | +| Mo | 0.025 | +| Mn | 0.02 | +| Si | 0.01 | +| C | 0.005 | diff --git a/docs/catalog/metals/stainless.md b/docs/catalog/metals/stainless.md new file mode 100644 index 0000000..295be84 --- /dev/null +++ b/docs/catalog/metals/stainless.md @@ -0,0 +1,49 @@ +# Stainless Steel + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.0 g/cm³ | +| Young's Modulus | 193 GPa | +| Poisson's Ratio | 0.27 | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1450 °C | +| Thermal Conductivity | 15.1 W/(m·K) | +| Specific Heat | 500 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.75, 0.75, 0.77, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | + +## Visual (mat-vis) + +| Field | Value | +|---|---| +| Source ID | `ambientcg/Metal032` | +| Finish | brushed | +| Available Finishes | brushed, polished | + +## Composition + +| Element | Fraction | +|---|---| +| Fe | 0.68 | +| Cr | 0.18 | +| Ni | 0.1 | +| Mn | 0.02 | +| Si | 0.01 | +| C | 0.005 | diff --git a/docs/catalog/metals/titanium-grade5.md b/docs/catalog/metals/titanium-grade5.md new file mode 100644 index 0000000..cfb4b55 --- /dev/null +++ b/docs/catalog/metals/titanium-grade5.md @@ -0,0 +1,39 @@ +# Ti-6Al-4V (Grade 5) + +## Identity + +| Field | Value | +|---|---| +| Grade | grade5 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 4.43 g/cm³ | +| Young's Modulus | 103 GPa | +| Yield Strength | 880 MPa | +| Tensile Strength | 950 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1668 °C | +| Thermal Conductivity | 21.9 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.6, 0.6, 0.6, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | + +## Composition + +| Element | Fraction | +|---|---| +| Ti | 0.9 | +| Al | 0.06 | +| V | 0.04 | diff --git a/docs/catalog/metals/titanium.md b/docs/catalog/metals/titanium.md new file mode 100644 index 0000000..0568267 --- /dev/null +++ b/docs/catalog/metals/titanium.md @@ -0,0 +1,31 @@ +# Titanium + +## Identity + +| Field | Value | +|---|---| +| Formula | `Ti` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 4.51 g/cm³ | +| Young's Modulus | 103 GPa | +| Yield Strength | 880 MPa | +| Tensile Strength | 950 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1668 °C | +| Thermal Conductivity | 21.9 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.6, 0.6, 0.6, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.3 | diff --git a/docs/catalog/metals/tungsten-W90.md b/docs/catalog/metals/tungsten-W90.md new file mode 100644 index 0000000..5e95d14 --- /dev/null +++ b/docs/catalog/metals/tungsten-W90.md @@ -0,0 +1,38 @@ +# Tungsten Heavy Alloy (90% W) + +## Identity + +| Field | Value | +|---|---| +| Grade | W90 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 17.0 g/cm³ | +| Young's Modulus | 411 GPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 3422 °C | +| Thermal Conductivity | 173 W/(m·K) | +| Specific Heat | 133 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.2, 0.2, 0.22, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | + +## Composition + +| Element | Fraction | +|---|---| +| W | 0.9 | +| Ni | 0.07 | +| Fe | 0.03 | diff --git a/docs/catalog/metals/tungsten-pure.md b/docs/catalog/metals/tungsten-pure.md new file mode 100644 index 0000000..e9a2f94 --- /dev/null +++ b/docs/catalog/metals/tungsten-pure.md @@ -0,0 +1,30 @@ +# Tungsten 99.95% + +## Identity + +| Field | Value | +|---|---| +| Grade | pure | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 19.3 g/cm³ | +| Young's Modulus | 411 GPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 3422 °C | +| Thermal Conductivity | 173 W/(m·K) | +| Specific Heat | 133 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.2, 0.2, 0.22, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/metals/tungsten.md b/docs/catalog/metals/tungsten.md new file mode 100644 index 0000000..0ace466 --- /dev/null +++ b/docs/catalog/metals/tungsten.md @@ -0,0 +1,30 @@ +# Tungsten + +## Identity + +| Field | Value | +|---|---| +| Formula | `W` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 19.3 g/cm³ | +| Young's Modulus | 411 GPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 3422 °C | +| Thermal Conductivity | 173 W/(m·K) | +| Specific Heat | 133 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.2, 0.2, 0.22, 1.0)` | +| Metallic | 1.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/plastics/README.md b/docs/catalog/plastics/README.md new file mode 100644 index 0000000..8e4b814 --- /dev/null +++ b/docs/catalog/plastics/README.md @@ -0,0 +1,28 @@ +# Plastics + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [PEEK](peek.md) | 1.32 g/cm³ | 0.5 | 0.0 | +| [PEEK Unfilled](peek-unfilled.md) | 1.32 g/cm³ | 0.5 | 0.0 | +| [PEEK 30% Glass Filled](peek-GF30.md) | 1.53 g/cm³ | 0.5 | 0.0 | +| [PEEK 30% Carbon Filled](peek-CF30.md) | 1.41 g/cm³ | 0.5 | 0.0 | +| [Victrex PEEK](peek-victrex.md) | 1.32 g/cm³ | 0.5 | 0.0 | +| [Delrin (POM)](delrin.md) | 1.41 g/cm³ | 0.6 | 0.0 | +| [Ultem (PEI)](ultem.md) | 1.27 g/cm³ | 0.5 | 0.0 | +| [PTFE (Teflon)](ptfe.md) | 2.15 g/cm³ | 0.3 | 0.0 | +| [PTFE Reflector Tape](ptfe-reflector.md) | 2.15 g/cm³ | 0.2 | 0.0 | +| [ESR (3M Vikuiti)](esr.md) | 1.0 g/cm³ | 0.1 | 0.0 | +| [Nylon 6](nylon.md) | 1.13 g/cm³ | 0.6 | 0.0 | +| [PLA (Polylactic Acid)](pla.md) | 1.25 g/cm³ | 0.6 | 0.0 | +| [ABS (Acrylonitrile Butadiene Styrene)](abs.md) | 1.05 g/cm³ | 0.6 | 0.0 | +| [PETG](petg.md) | 1.27 g/cm³ | 0.5 | 0.0 | +| [TPU (Thermoplastic Polyurethane)](tpu.md) | 1.21 g/cm³ | 0.7 | 0.0 | +| [Vespel (Polyimide)](vespel.md) | 1.42 g/cm³ | 0.5 | 0.0 | +| [Torlon (PAI)](torlon.md) | 1.45 g/cm³ | 0.5 | 0.0 | +| [PCTFE (Polychlorotrifluoroethylene)](pctfe.md) | 2.13 g/cm³ | 0.4 | 0.0 | +| [PMMA (Acrylic)](pmma.md) | 1.18 g/cm³ | 0.1 | 0.0 | +| [Polyethylene](pe.md) | 0.94 g/cm³ | 0.6 | 0.0 | +| [HDPE (High-Density Polyethylene)](pe-hdpe.md) | 0.95 g/cm³ | 0.6 | 0.0 | +| [LDPE (Low-Density Polyethylene)](pe-ldpe.md) | 0.92 g/cm³ | 0.6 | 0.0 | +| [UHMWPE](pe-uhmwpe.md) | 0.93 g/cm³ | 0.6 | 0.0 | +| [Polycarbonate](pc.md) | 1.2 g/cm³ | 0.15 | 0.0 | diff --git a/docs/catalog/plastics/abs.md b/docs/catalog/plastics/abs.md new file mode 100644 index 0000000..1c347af --- /dev/null +++ b/docs/catalog/plastics/abs.md @@ -0,0 +1,28 @@ +# ABS (Acrylonitrile Butadiene Styrene) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.05 g/cm³ | +| Young's Modulus | 2.3 GPa | +| Yield Strength | 40 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 225 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.3, 0.3, 0.3, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/delrin.md b/docs/catalog/plastics/delrin.md new file mode 100644 index 0000000..215c66c --- /dev/null +++ b/docs/catalog/plastics/delrin.md @@ -0,0 +1,31 @@ +# Delrin (POM) + +## Identity + +| Field | Value | +|---|---| +| Formula | `-(CH2-O)n-` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.41 g/cm³ | +| Young's Modulus | 2.8 GPa | +| Yield Strength | 70 MPa | +| Tensile Strength | 70 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 165 °C | +| Thermal Conductivity | 0.25 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.85, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/esr.md b/docs/catalog/plastics/esr.md new file mode 100644 index 0000000..db5d3ff --- /dev/null +++ b/docs/catalog/plastics/esr.md @@ -0,0 +1,21 @@ +# ESR (3M Vikuiti) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.0 g/cm³ | +| Tensile Strength | 100 MPa | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.99, 0.99, 0.99, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.1 | diff --git a/docs/catalog/plastics/nylon.md b/docs/catalog/plastics/nylon.md new file mode 100644 index 0000000..db5c2fa --- /dev/null +++ b/docs/catalog/plastics/nylon.md @@ -0,0 +1,29 @@ +# Nylon 6 + +## Identity + +| Field | Value | +|---|---| +| Formula | `-(CH2)5-CO-NH-` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.13 g/cm³ | +| Young's Modulus | 2.8 GPa | +| Yield Strength | 45 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 215 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.85, 0.8, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/pc.md b/docs/catalog/plastics/pc.md new file mode 100644 index 0000000..da9f3f7 --- /dev/null +++ b/docs/catalog/plastics/pc.md @@ -0,0 +1,34 @@ +# Polycarbonate + +## Identity + +| Field | Value | +|---|---| +| Formula | `C16H14O3` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.2 g/cm³ | +| Young's Modulus | 2.3 GPa | +| Yield Strength | 60 MPa | +| Tensile Strength | 65 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 267 °C | +| Thermal Conductivity | 0.2 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.92, 0.92, 0.92, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.15 | +| IOR | 1.58 | +| Transmission | 0.85 | diff --git a/docs/catalog/plastics/pctfe.md b/docs/catalog/plastics/pctfe.md new file mode 100644 index 0000000..4f7d0ab --- /dev/null +++ b/docs/catalog/plastics/pctfe.md @@ -0,0 +1,27 @@ +# PCTFE (Polychlorotrifluoroethylene) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.13 g/cm³ | +| Yield Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 217 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.95, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.4 | diff --git a/docs/catalog/plastics/pe-hdpe.md b/docs/catalog/plastics/pe-hdpe.md new file mode 100644 index 0000000..1aa84cd --- /dev/null +++ b/docs/catalog/plastics/pe-hdpe.md @@ -0,0 +1,32 @@ +# HDPE (High-Density Polyethylene) + +## Identity + +| Field | Value | +|---|---| +| Grade | HDPE | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.95 g/cm³ | +| Young's Modulus | 1.1 GPa | +| Yield Strength | 26 MPa | +| Tensile Strength | 32 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 130 °C | +| Thermal Conductivity | 0.4 W/(m·K) | +| Specific Heat | 2300 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.88, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/pe-ldpe.md b/docs/catalog/plastics/pe-ldpe.md new file mode 100644 index 0000000..14c93a4 --- /dev/null +++ b/docs/catalog/plastics/pe-ldpe.md @@ -0,0 +1,32 @@ +# LDPE (Low-Density Polyethylene) + +## Identity + +| Field | Value | +|---|---| +| Grade | LDPE | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.92 g/cm³ | +| Young's Modulus | 0.2 GPa | +| Yield Strength | 10 MPa | +| Tensile Strength | 15 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 110 °C | +| Thermal Conductivity | 0.4 W/(m·K) | +| Specific Heat | 2300 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.88, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/pe-uhmwpe.md b/docs/catalog/plastics/pe-uhmwpe.md new file mode 100644 index 0000000..aaf005c --- /dev/null +++ b/docs/catalog/plastics/pe-uhmwpe.md @@ -0,0 +1,32 @@ +# UHMWPE + +## Identity + +| Field | Value | +|---|---| +| Grade | UHMWPE | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.93 g/cm³ | +| Young's Modulus | 0.8 GPa | +| Yield Strength | 20 MPa | +| Tensile Strength | 40 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 130 °C | +| Thermal Conductivity | 0.4 W/(m·K) | +| Specific Heat | 2300 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.88, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/pe.md b/docs/catalog/plastics/pe.md new file mode 100644 index 0000000..80eaa9e --- /dev/null +++ b/docs/catalog/plastics/pe.md @@ -0,0 +1,31 @@ +# Polyethylene + +## Identity + +| Field | Value | +|---|---| +| Formula | `-(CH2-CH2)n-` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 0.94 g/cm³ | +| Young's Modulus | 0.8 GPa | +| Yield Strength | 25 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 130 °C | +| Thermal Conductivity | 0.4 W/(m·K) | +| Specific Heat | 2300 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.88, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/peek-CF30.md b/docs/catalog/plastics/peek-CF30.md new file mode 100644 index 0000000..6102ab2 --- /dev/null +++ b/docs/catalog/plastics/peek-CF30.md @@ -0,0 +1,32 @@ +# PEEK 30% Carbon Filled + +## Identity + +| Field | Value | +|---|---| +| Grade | CF30 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.41 g/cm³ | +| Young's Modulus | 13 GPa | +| Yield Strength | 100 MPa | +| Tensile Strength | 100 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 334 °C | +| Thermal Conductivity | 0.25 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.82, 0.7, 0.55, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/peek-GF30.md b/docs/catalog/plastics/peek-GF30.md new file mode 100644 index 0000000..a4daf3d --- /dev/null +++ b/docs/catalog/plastics/peek-GF30.md @@ -0,0 +1,32 @@ +# PEEK 30% Glass Filled + +## Identity + +| Field | Value | +|---|---| +| Grade | GF30 | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.53 g/cm³ | +| Young's Modulus | 10 GPa | +| Yield Strength | 170 MPa | +| Tensile Strength | 100 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 334 °C | +| Thermal Conductivity | 0.25 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.82, 0.7, 0.55, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/peek-unfilled.md b/docs/catalog/plastics/peek-unfilled.md new file mode 100644 index 0000000..b5ab3bc --- /dev/null +++ b/docs/catalog/plastics/peek-unfilled.md @@ -0,0 +1,32 @@ +# PEEK Unfilled + +## Identity + +| Field | Value | +|---|---| +| Grade | unfilled | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.32 g/cm³ | +| Young's Modulus | 3.6 GPa | +| Yield Strength | 100 MPa | +| Tensile Strength | 100 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 334 °C | +| Thermal Conductivity | 0.25 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.82, 0.7, 0.55, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/peek-victrex.md b/docs/catalog/plastics/peek-victrex.md new file mode 100644 index 0000000..d533612 --- /dev/null +++ b/docs/catalog/plastics/peek-victrex.md @@ -0,0 +1,31 @@ +# Victrex PEEK + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.32 g/cm³ | +| Young's Modulus | 3.6 GPa | +| Yield Strength | 100 MPa | +| Tensile Strength | 100 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 334 °C | +| Thermal Conductivity | 0.25 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.82, 0.7, 0.55, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/peek.md b/docs/catalog/plastics/peek.md new file mode 100644 index 0000000..9494d5e --- /dev/null +++ b/docs/catalog/plastics/peek.md @@ -0,0 +1,32 @@ +# PEEK + +## Identity + +| Field | Value | +|---|---| +| Formula | `C19H12O3` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.32 g/cm³ | +| Young's Modulus | 3.6 GPa | +| Yield Strength | 100 MPa | +| Tensile Strength | 100 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 334 °C | +| Thermal Conductivity | 0.25 W/(m·K) | +| Specific Heat | 1200 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.82, 0.7, 0.55, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/petg.md b/docs/catalog/plastics/petg.md new file mode 100644 index 0000000..bda57ca --- /dev/null +++ b/docs/catalog/plastics/petg.md @@ -0,0 +1,27 @@ +# PETG + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.27 g/cm³ | +| Young's Modulus | 2.3 GPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 225 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.85, 0.8, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/pla.md b/docs/catalog/plastics/pla.md new file mode 100644 index 0000000..9401c44 --- /dev/null +++ b/docs/catalog/plastics/pla.md @@ -0,0 +1,29 @@ +# PLA (Polylactic Acid) + +## Identity + +| Field | Value | +|---|---| +| Formula | `C3H4O2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.25 g/cm³ | +| Young's Modulus | 2.7 GPa | +| Yield Strength | 50 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 160 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.8, 0.8, 0.8, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.6 | diff --git a/docs/catalog/plastics/pmma.md b/docs/catalog/plastics/pmma.md new file mode 100644 index 0000000..d0f5c26 --- /dev/null +++ b/docs/catalog/plastics/pmma.md @@ -0,0 +1,34 @@ +# PMMA (Acrylic) + +## Identity + +| Field | Value | +|---|---| +| Formula | `C5H8O2` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.18 g/cm³ | +| Young's Modulus | 3.0 GPa | +| Yield Strength | 70 MPa | +| Tensile Strength | 75 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 160 °C | +| Thermal Conductivity | 0.19 W/(m·K) | +| Specific Heat | 1470 J/(kg·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.95, 0.9)` | +| Metallic | 0.0 | +| Roughness | 0.1 | +| IOR | 1.49 | +| Transmission | 0.9 | diff --git a/docs/catalog/plastics/ptfe-reflector.md b/docs/catalog/plastics/ptfe-reflector.md new file mode 100644 index 0000000..d8e08a3 --- /dev/null +++ b/docs/catalog/plastics/ptfe-reflector.md @@ -0,0 +1,29 @@ +# PTFE Reflector Tape + +## Identity + +| Field | Value | +|---|---| +| Treatment | reflector | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.15 g/cm³ | +| Yield Strength | 20 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 327 °C | +| Thermal Conductivity | 0.24 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.98, 0.98, 0.98, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.2 | diff --git a/docs/catalog/plastics/ptfe.md b/docs/catalog/plastics/ptfe.md new file mode 100644 index 0000000..2cbc805 --- /dev/null +++ b/docs/catalog/plastics/ptfe.md @@ -0,0 +1,29 @@ +# PTFE (Teflon) + +## Identity + +| Field | Value | +|---|---| +| Formula | `-(CF2-CF2)n-` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 2.15 g/cm³ | +| Yield Strength | 20 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 327 °C | +| Thermal Conductivity | 0.24 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.95, 0.95, 0.95, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.3 | diff --git a/docs/catalog/plastics/torlon.md b/docs/catalog/plastics/torlon.md new file mode 100644 index 0000000..a1f978c --- /dev/null +++ b/docs/catalog/plastics/torlon.md @@ -0,0 +1,28 @@ +# Torlon (PAI) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.45 g/cm³ | +| Young's Modulus | 5.2 GPa | +| Yield Strength | 110 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 330 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.6, 0.3, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/tpu.md b/docs/catalog/plastics/tpu.md new file mode 100644 index 0000000..61bd26d --- /dev/null +++ b/docs/catalog/plastics/tpu.md @@ -0,0 +1,20 @@ +# TPU (Thermoplastic Polyurethane) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.21 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.6, 0.6, 0.6, 0.8)` | +| Metallic | 0.0 | +| Roughness | 0.7 | diff --git a/docs/catalog/plastics/ultem.md b/docs/catalog/plastics/ultem.md new file mode 100644 index 0000000..2c60558 --- /dev/null +++ b/docs/catalog/plastics/ultem.md @@ -0,0 +1,30 @@ +# Ultem (PEI) + +## Identity + +| Field | Value | +|---|---| +| Formula | `C37H24N2O6` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.27 g/cm³ | +| Young's Modulus | 3.6 GPa | +| Yield Strength | 90 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 340 °C | +| Thermal Conductivity | 0.22 W/(m·K) | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.85, 0.65, 0.2, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/plastics/vespel.md b/docs/catalog/plastics/vespel.md new file mode 100644 index 0000000..99fc187 --- /dev/null +++ b/docs/catalog/plastics/vespel.md @@ -0,0 +1,28 @@ +# Vespel (Polyimide) + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.42 g/cm³ | +| Young's Modulus | 3.8 GPa | +| Yield Strength | 70 MPa | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 400 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.4, 0.2, 0.1, 1.0)` | +| Metallic | 0.0 | +| Roughness | 0.5 | diff --git a/docs/catalog/scintillators/README.md b/docs/catalog/scintillators/README.md new file mode 100644 index 0000000..7fd3f0c --- /dev/null +++ b/docs/catalog/scintillators/README.md @@ -0,0 +1,17 @@ +# Scintillators + +| Material | Density | Roughness | Metallic | +|---|---|---|---| +| [LYSO](lyso.md) | 7.1 g/cm³ | 0.3 | 0.0 | +| [LYSO:Ce](lyso-Ce.md) | 7.1 g/cm³ | 0.3 | 0.0 | +| [BGO](bgo.md) | 7.13 g/cm³ | 0.3 | 0.0 | +| [NaI](nai.md) | 3.67 g/cm³ | 0.2 | 0.0 | +| [NaI(Tl)](nai-Tl.md) | 3.67 g/cm³ | 0.2 | 0.0 | +| [CsI](csi.md) | 4.51 g/cm³ | 0.25 | 0.0 | +| [CsI(Tl)](csi-Tl.md) | 4.51 g/cm³ | 0.25 | 0.0 | +| [CsI(Na)](csi-Na.md) | 4.51 g/cm³ | 0.25 | 0.0 | +| [LaBr3:Ce](labr3.md) | 5.29 g/cm³ | 0.3 | 0.0 | +| [PWO](pwo.md) | 8.28 g/cm³ | 0.35 | 0.0 | +| [Plastic Scintillator](plastic_scint.md) | 1.032 g/cm³ | 0.4 | 0.0 | +| [BC-400 Plastic Scintillator](plastic_scint-BC400.md) | 1.032 g/cm³ | 0.4 | 0.0 | +| [EJ-200 Plastic Scintillator](plastic_scint-EJ200.md) | 1.032 g/cm³ | 0.4 | 0.0 | diff --git a/docs/catalog/scintillators/bgo.md b/docs/catalog/scintillators/bgo.md new file mode 100644 index 0000000..4a25782 --- /dev/null +++ b/docs/catalog/scintillators/bgo.md @@ -0,0 +1,29 @@ +# BGO + +## Identity + +| Field | Value | +|---|---| +| Formula | `Bi4Ge3O12` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 7.13 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 1050 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.58, 0.44, 0.86, 0.8)` | +| Metallic | 0.0 | +| Roughness | 0.3 | +| IOR | 2.15 | +| Transmission | 0.75 | diff --git a/docs/catalog/scintillators/csi-Na.md b/docs/catalog/scintillators/csi-Na.md new file mode 100644 index 0000000..921605d --- /dev/null +++ b/docs/catalog/scintillators/csi-Na.md @@ -0,0 +1,23 @@ +# CsI(Na) + +## Identity + +| Field | Value | +|---|---| +| Formula | `CsI:Na` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 4.51 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.7, 0.75)` | +| Metallic | 0.0 | +| Roughness | 0.25 | +| IOR | 1.95 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/csi-Tl.md b/docs/catalog/scintillators/csi-Tl.md new file mode 100644 index 0000000..f0a7782 --- /dev/null +++ b/docs/catalog/scintillators/csi-Tl.md @@ -0,0 +1,23 @@ +# CsI(Tl) + +## Identity + +| Field | Value | +|---|---| +| Formula | `CsI:Tl` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 4.51 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.7, 0.75)` | +| Metallic | 0.0 | +| Roughness | 0.25 | +| IOR | 1.95 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/csi.md b/docs/catalog/scintillators/csi.md new file mode 100644 index 0000000..f35f5dd --- /dev/null +++ b/docs/catalog/scintillators/csi.md @@ -0,0 +1,23 @@ +# CsI + +## Identity + +| Field | Value | +|---|---| +| Formula | `CsI` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 4.51 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.7, 0.75)` | +| Metallic | 0.0 | +| Roughness | 0.25 | +| IOR | 1.95 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/labr3.md b/docs/catalog/scintillators/labr3.md new file mode 100644 index 0000000..90ba806 --- /dev/null +++ b/docs/catalog/scintillators/labr3.md @@ -0,0 +1,23 @@ +# LaBr3:Ce + +## Identity + +| Field | Value | +|---|---| +| Formula | `LaBr3:Ce` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 5.29 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.8, 0.7, 0.9, 0.8)` | +| Metallic | 0.0 | +| Roughness | 0.3 | +| IOR | 1.9 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/lyso-Ce.md b/docs/catalog/scintillators/lyso-Ce.md new file mode 100644 index 0000000..990ac8d --- /dev/null +++ b/docs/catalog/scintillators/lyso-Ce.md @@ -0,0 +1,29 @@ +# LYSO:Ce + +## Identity + +| Field | Value | +|---|---| +| Formula | `Lu1.8Y0.2SiO5:Ce` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 7.1 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2050 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.0, 1.0, 1.0, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.3 | +| IOR | 1.82 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/lyso.md b/docs/catalog/scintillators/lyso.md new file mode 100644 index 0000000..85809b9 --- /dev/null +++ b/docs/catalog/scintillators/lyso.md @@ -0,0 +1,29 @@ +# LYSO + +## Identity + +| Field | Value | +|---|---| +| Formula | `Lu1.8Y0.2SiO5` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 7.1 g/cm³ | + +## Thermal Properties + +| Property | Value | +|---|---| +| Melting Point | 2050 °C | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.0, 1.0, 1.0, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.3 | +| IOR | 1.82 | +| Transmission | 0.8 | diff --git a/docs/catalog/scintillators/nai-Tl.md b/docs/catalog/scintillators/nai-Tl.md new file mode 100644 index 0000000..91b0102 --- /dev/null +++ b/docs/catalog/scintillators/nai-Tl.md @@ -0,0 +1,23 @@ +# NaI(Tl) + +## Identity + +| Field | Value | +|---|---| +| Formula | `NaI:Tl` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 3.67 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(1.0, 0.9, 0.7, 0.7)` | +| Metallic | 0.0 | +| Roughness | 0.2 | +| IOR | 1.85 | +| Transmission | 0.85 | diff --git a/docs/catalog/scintillators/nai.md b/docs/catalog/scintillators/nai.md new file mode 100644 index 0000000..df7ff51 --- /dev/null +++ b/docs/catalog/scintillators/nai.md @@ -0,0 +1,23 @@ +# NaI + +## Identity + +| Field | Value | +|---|---| +| Formula | `NaI` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 3.67 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(1.0, 0.9, 0.7, 0.7)` | +| Metallic | 0.0 | +| Roughness | 0.2 | +| IOR | 1.85 | +| Transmission | 0.85 | diff --git a/docs/catalog/scintillators/plastic_scint-BC400.md b/docs/catalog/scintillators/plastic_scint-BC400.md new file mode 100644 index 0000000..5bb70f6 --- /dev/null +++ b/docs/catalog/scintillators/plastic_scint-BC400.md @@ -0,0 +1,21 @@ +# BC-400 Plastic Scintillator + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.032 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.85, 0.9)` | +| Metallic | 0.0 | +| Roughness | 0.4 | +| Transmission | 0.85 | diff --git a/docs/catalog/scintillators/plastic_scint-EJ200.md b/docs/catalog/scintillators/plastic_scint-EJ200.md new file mode 100644 index 0000000..91ba9c8 --- /dev/null +++ b/docs/catalog/scintillators/plastic_scint-EJ200.md @@ -0,0 +1,21 @@ +# EJ-200 Plastic Scintillator + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.032 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.85, 0.9)` | +| Metallic | 0.0 | +| Roughness | 0.4 | +| Transmission | 0.85 | diff --git a/docs/catalog/scintillators/plastic_scint.md b/docs/catalog/scintillators/plastic_scint.md new file mode 100644 index 0000000..01d47b3 --- /dev/null +++ b/docs/catalog/scintillators/plastic_scint.md @@ -0,0 +1,21 @@ +# Plastic Scintillator + +## Identity + +| Field | Value | +|---|---| + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 1.032 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.9, 0.9, 0.85, 0.9)` | +| Metallic | 0.0 | +| Roughness | 0.4 | +| Transmission | 0.85 | diff --git a/docs/catalog/scintillators/pwo.md b/docs/catalog/scintillators/pwo.md new file mode 100644 index 0000000..475257c --- /dev/null +++ b/docs/catalog/scintillators/pwo.md @@ -0,0 +1,23 @@ +# PWO + +## Identity + +| Field | Value | +|---|---| +| Formula | `PbWO4` | + +## Mechanical Properties + +| Property | Value | +|---|---| +| Density | 8.28 g/cm³ | + +## PBR (Rendering) + +| Property | Value | +|---|---| +| Base Color | `(0.4, 0.4, 0.45, 0.85)` | +| Metallic | 0.0 | +| Roughness | 0.35 | +| IOR | 2.26 | +| Transmission | 0.7 | diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py index 7db3252..18bbfdb 100644 --- a/scripts/generate_catalog.py +++ b/scripts/generate_catalog.py @@ -156,8 +156,16 @@ def _material_page(mat, thumb_path: str | None, category: str) -> str: lines.append("") lines.append("| Element | Fraction |") lines.append("|---|---|") - for el, frac in sorted(mat.composition.items(), key=lambda x: -x[1]): - lines.append(f"| {el} | {frac:.4g} |") + for el, frac in sorted( + mat.composition.items(), + key=lambda x: -(getattr(x[1], "nominal_value", x[1])), + ): + nominal = getattr(frac, "nominal_value", frac) + stddev = getattr(frac, "std_dev", None) + if stddev and stddev > 0: + lines.append(f"| {el} | {nominal:.4g} ± {stddev:.4g} |") + else: + lines.append(f"| {el} | {nominal:.4g} |") lines.append("") return "\n".join(lines) diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index b346876..f2de544 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -16,13 +16,77 @@ """ from mat_vis_client import ( + MatVisClient as _MatVisClient, fetch, get_manifest, prefetch, rowmap_entry, - search, + search as _upstream_search, ) +from typing import Any + + +def search( + *, + category: str | None = None, + roughness: float | None = None, + metalness: float | None = None, + source: str | None = None, + limit: int = 20, +) -> list[dict[str, Any]]: + """Search the mat-vis index by category and scalar similarity. + + Does NOT filter by tier — search is for finding materials, + tier is a fetch-time concern. + """ + from mat_vis_client import _get_client + + client = _get_client() + + roughness_range = None + if roughness is not None: + roughness_range = (max(0.0, roughness - 0.2), min(1.0, roughness + 0.2)) + + metalness_range = None + if metalness is not None: + metalness_range = (max(0.0, metalness - 0.2), min(1.0, metalness + 0.2)) + + # Search all sources, no tier filter + sources = [source] if source else client.sources() + results: list[dict] = [] + for src in sources: + try: + for entry in client.index(src): + if category and entry.get("category") != category: + continue + if roughness_range and not ( + entry.get("roughness") is not None + and roughness_range[0] <= entry["roughness"] <= roughness_range[1] + ): + continue + if metalness_range and not ( + entry.get("metalness") is not None + and metalness_range[0] <= entry["metalness"] <= metalness_range[1] + ): + continue + results.append(entry) + except Exception: + continue + + # Score by scalar distance + for r in results: + score = 0.0 + if roughness is not None and r.get("roughness") is not None: + score += abs(r["roughness"] - roughness) + if metalness is not None and r.get("metalness") is not None: + score += abs(r["metalness"] - metalness) + r["score"] = score + + results.sort(key=lambda r: r["score"]) + return results[:limit] + + __all__ = [ "search", "fetch", From 9eaba5120984e15e8cb1c2fcf218245ec9ce3ba7 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 17:06:16 +0200 Subject: [PATCH 21/32] =?UTF-8?q?test:=20add=2011=20adapter=20tests=20?= =?UTF-8?q?=E2=80=94=20adapters.py=20now=20100%=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests for _extract_scalars (pbr fallback, vis-wins, metallic→metalness mapping), _extract_textures, to_threejs (scalar + texture modes), to_gltf, export_mtlx (with/without textures). Overall coverage: 81% (158 passed, 26 skipped). --- tests/test_adapters.py | 127 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_adapters.py diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..36e971e --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,127 @@ +"""Tests for pymat.vis.adapters — Material → format wrappers.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from pymat import Material +from pymat.vis.adapters import ( + _extract_scalars, + _extract_textures, + export_mtlx, + to_gltf, + to_threejs, +) + + +def _make_material(with_vis=False): + """Create a test material with optional vis textures.""" + m = Material( + name="Test Steel", + pbr={ + "metallic": 1.0, + "roughness": 0.3, + "base_color": (0.8, 0.8, 0.8, 1.0), + "ior": 2.5, + "transmission": 0.0, + "clearcoat": 0.0, + "emissive": (0, 0, 0), + }, + ) + if with_vis: + m.vis._textures = { + "color": b"\x89PNG_color", + "normal": b"\x89PNG_normal", + "roughness": b"\x89PNG_roughness", + } + m.vis._fetched = True + m.vis.source_id = "ambientcg/Metal032" + return m + + +class TestExtractScalars: + def test_from_pbr(self): + m = _make_material() + s = _extract_scalars(m) + assert s["metalness"] == 1.0 + assert s["roughness"] == 0.3 + assert s["ior"] == 2.5 + + def test_vis_wins_over_pbr(self): + m = _make_material() + m.vis.roughness = 0.5 # vis override + s = _extract_scalars(m) + assert s["roughness"] == 0.5 # vis wins + assert s["metalness"] == 1.0 # falls back to pbr + + def test_metallic_mapped_to_metalness(self): + m = _make_material() + s = _extract_scalars(m) + assert "metalness" in s + assert "metallic" not in s + + +class TestExtractTextures: + def test_no_vis_returns_empty(self): + m = _make_material(with_vis=False) + assert _extract_textures(m) == {} + + def test_with_vis_returns_textures(self): + m = _make_material(with_vis=True) + tex = _extract_textures(m) + assert "color" in tex + assert tex["color"] == b"\x89PNG_color" + + +class TestToThreejs: + def test_scalar_only(self): + m = _make_material(with_vis=False) + d = to_threejs(m) + assert d["metalness"] == 1.0 + assert d["roughness"] == 0.3 + assert "map" not in d # no textures + + def test_with_textures(self): + m = _make_material(with_vis=True) + d = to_threejs(m) + assert "map" in d # base64 data URI + assert d["map"].startswith("data:image/png;base64,") + assert "normalMap" in d + assert "roughnessMap" in d + + +class TestToGltf: + def test_scalar_only(self): + m = _make_material(with_vis=False) + d = to_gltf(m) + assert d["name"] == "Test Steel" + assert "pbrMetallicRoughness" in d + pbr = d["pbrMetallicRoughness"] + assert pbr["metallicFactor"] == 1.0 + assert pbr["roughnessFactor"] == 0.3 + + def test_with_textures(self): + m = _make_material(with_vis=True) + d = to_gltf(m) + assert d["name"] == "Test Steel" + + +class TestExportMtlx: + def test_export_creates_files(self): + m = _make_material(with_vis=True) + with tempfile.TemporaryDirectory() as tmp: + mtlx_path = export_mtlx(m, Path(tmp)) + assert mtlx_path.exists() + assert mtlx_path.suffix == ".mtlx" + # Check PNGs were written + pngs = list(Path(tmp).glob("*.png")) + assert len(pngs) == 3 # color, normal, roughness + + def test_export_no_textures(self): + m = _make_material(with_vis=False) + with tempfile.TemporaryDirectory() as tmp: + mtlx_path = export_mtlx(m, Path(tmp)) + assert mtlx_path.exists() + pngs = list(Path(tmp).glob("*.png")) + assert len(pngs) == 0 # no textures From fc8ef18d0513fada1fdf05a48243e663fc16316e Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:11:55 +0200 Subject: [PATCH 22/32] data: migrate all 87 [pbr] TOML sections to [vis] Every [material.pbr] section across 7 data files renamed to [material.vis]. Zero [pbr] sections remain. Values unchanged. The loader already routes [vis] scalars to both .vis and properties.pbr (backward compat sync), so all existing tests pass without code changes. 128 materials load. 170 tests pass. Part of 3.0.0 migration (#40). --- src/pymat/data/ceramics.toml | 18 ++++++------ src/pymat/data/electronics.toml | 22 +++++++-------- src/pymat/data/gases.toml | 28 +++++++++---------- src/pymat/data/liquids.toml | 12 ++++---- src/pymat/data/metals.toml | 46 +++++++++++++++---------------- src/pymat/data/plastics.toml | 34 +++++++++++------------ src/pymat/data/scintillators.toml | 14 +++++----- 7 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/pymat/data/ceramics.toml b/src/pymat/data/ceramics.toml index 94bfcb5..26b0fb4 100644 --- a/src/pymat/data/ceramics.toml +++ b/src/pymat/data/ceramics.toml @@ -30,7 +30,7 @@ dielectric_constant = 9.0 breakdown_voltage_value = 12 breakdown_voltage_unit = "kV/mm" -[alumina.pbr] +[alumina.vis] base_color = [0.95, 0.95, 0.93, 1.0] transmission = 0.0 metallic = 0.0 @@ -83,7 +83,7 @@ thermal_conductivity_unit = "W/(m*K)" [zirconia.electrical] dielectric_constant = 25 -[zirconia.pbr] +[zirconia.vis] base_color = [0.9, 0.88, 0.85, 1.0] transmission = 0.0 metallic = 0.0 @@ -115,7 +115,7 @@ thermal_conductivity_unit = "W/(m*K)" [sic.electrical] dielectric_constant = 10 -[sic.pbr] +[sic.vis] base_color = [0.5, 0.5, 0.5, 1.0] transmission = 0.0 metallic = 0.0 @@ -148,7 +148,7 @@ dielectric_constant = 5.8 volume_resistivity_value = 1e11 volume_resistivity_unit = "ohm*cm" -[macor.pbr] +[macor.vis] base_color = [0.98, 0.98, 0.96, 1.0] transmission = 0.0 metallic = 0.0 @@ -177,7 +177,7 @@ melting_point_unit = "degC" thermal_conductivity_value = 17 thermal_conductivity_unit = "W/(m*K)" -[shapal.pbr] +[shapal.vis] base_color = [0.85, 0.82, 0.8, 1.0] transmission = 0.0 metallic = 0.0 @@ -215,7 +215,7 @@ dielectric_constant = 6.0 breakdown_voltage_value = 15 breakdown_voltage_unit = "kV/mm" -[glass.pbr] +[glass.vis] base_color = [0.9, 0.93, 0.95, 0.85] metallic = 0.0 roughness = 0.1 @@ -259,7 +259,7 @@ grade = "BK7" [glass.BK7.optical] refractive_index = 1.517 -[glass.BK7.pbr] +[glass.BK7.vis] transmission = 0.95 @@ -286,7 +286,7 @@ thermal_conductivity_unit = "W/(m*K)" [beryllia.electrical] dielectric_constant = 7.35 -[beryllia.pbr] +[beryllia.vis] base_color = [0.9, 0.9, 0.9, 1.0] transmission = 0.0 metallic = 0.0 @@ -319,7 +319,7 @@ thermal_conductivity_unit = "W/(m*K)" [yttria.electrical] dielectric_constant = 10 -[yttria.pbr] +[yttria.vis] base_color = [0.95, 0.93, 0.9, 1.0] transmission = 0.0 metallic = 0.0 diff --git a/src/pymat/data/electronics.toml b/src/pymat/data/electronics.toml index ee1d954..0ce87b5 100644 --- a/src/pymat/data/electronics.toml +++ b/src/pymat/data/electronics.toml @@ -32,7 +32,7 @@ volume_resistivity_unit = "ohm*cm" breakdown_voltage_value = 15 breakdown_voltage_unit = "kV/mm" -[fr4.pbr] +[fr4.vis] base_color = [0.15, 0.15, 0.15, 1.0] metallic = 0.0 roughness = 0.7 @@ -60,7 +60,7 @@ density_unit = "g/cm^3" [rogers.electrical] dielectric_constant = 3.5 -[rogers.pbr] +[rogers.vis] base_color = [0.2, 0.2, 0.2, 1.0] metallic = 0.0 roughness = 0.6 @@ -106,7 +106,7 @@ dielectric_constant = 3.5 breakdown_voltage_value = 600 breakdown_voltage_unit = "kV/mm" -[kapton.pbr] +[kapton.vis] base_color = [0.95, 0.85, 0.4, 1.0] metallic = 0.0 roughness = 0.5 @@ -133,7 +133,7 @@ resistivity_unit = "ohm*m" conductivity_value = 5.96e7 conductivity_unit = "S/m" -[copper_pcb.pbr] +[copper_pcb.vis] base_color = [0.72, 0.45, 0.2, 1.0] metallic = 1.0 roughness = 0.35 @@ -154,7 +154,7 @@ grade = "2oz" name = "Gold Plated Copper (ENIG)" treatment = "gold_plated" -[copper_pcb.gold_plated.pbr] +[copper_pcb.gold_plated.vis] base_color = [1.0, 0.84, 0.0, 1.0] metallic = 1.0 roughness = 0.2 @@ -176,7 +176,7 @@ density_unit = "g/cm^3" resistivity_value = 1.5e-7 resistivity_unit = "ohm*m" -[solder.pbr] +[solder.vis] base_color = [0.7, 0.7, 0.7, 1.0] metallic = 1.0 roughness = 0.4 @@ -235,7 +235,7 @@ dielectric_constant = 3.8 volume_resistivity_value = 1e15 volume_resistivity_unit = "ohm*cm" -[epoxy_potting.pbr] +[epoxy_potting.vis] base_color = [0.5, 0.4, 0.3, 1.0] metallic = 0.0 roughness = 0.6 @@ -261,7 +261,7 @@ thermal_conductivity_unit = "W/(m*K)" [silicone_potting.electrical] dielectric_constant = 3.0 -[silicone_potting.pbr] +[silicone_potting.vis] base_color = [0.8, 0.6, 0.4, 1.0] metallic = 0.0 roughness = 0.7 @@ -286,7 +286,7 @@ density_unit = "g/cm^3" permeability = 100 dielectric_constant = 10 -[ferrite.pbr] +[ferrite.vis] base_color = [0.1, 0.1, 0.1, 1.0] metallic = 0.0 roughness = 0.8 @@ -309,7 +309,7 @@ dielectric_constant = 1000 breakdown_voltage_value = 100 breakdown_voltage_unit = "kV/mm" -[ceramic_capacitor.pbr] +[ceramic_capacitor.vis] base_color = [0.95, 0.8, 0.1, 1.0] metallic = 0.0 roughness = 0.5 @@ -342,7 +342,7 @@ thermal_conductivity_unit = "W/(m*K)" thermal_expansion_value = 5.8e-6 thermal_expansion_unit = "1/K" -[alumina_pcb.pbr] +[alumina_pcb.vis] base_color = [0.9, 0.88, 0.85, 1.0] metallic = 0.0 roughness = 0.4 diff --git a/src/pymat/data/gases.toml b/src/pymat/data/gases.toml index 91259d6..5441e83 100644 --- a/src/pymat/data/gases.toml +++ b/src/pymat/data/gases.toml @@ -24,7 +24,7 @@ specific_heat_unit = "J/(kg*K)" [air.optical] refractive_index = 1.000293 -[air.pbr] +[air.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -53,7 +53,7 @@ specific_heat_unit = "J/(kg*K)" [nitrogen.optical] refractive_index = 1.000298 -[nitrogen.pbr] +[nitrogen.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -75,7 +75,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 2042 specific_heat_unit = "J/(kg*K)" -[nitrogen.liquid.pbr] +[nitrogen.liquid.vis] base_color = [0.85, 0.9, 0.95, 0.4] transmission = 0.9 @@ -101,7 +101,7 @@ specific_heat_unit = "J/(kg*K)" [oxygen.optical] refractive_index = 1.000271 -[oxygen.pbr] +[oxygen.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -129,7 +129,7 @@ specific_heat_unit = "J/(kg*K)" [argon.optical] refractive_index = 1.000281 -[argon.pbr] +[argon.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -160,7 +160,7 @@ specific_heat_unit = "J/(kg*K)" [co2.optical] refractive_index = 1.000449 -[co2.pbr] +[co2.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -180,7 +180,7 @@ density_unit = "g/cm^3" thermal_conductivity_value = 0.6 thermal_conductivity_unit = "W/(m*K)" -[co2.dry_ice.pbr] +[co2.dry_ice.vis] base_color = [0.95, 0.95, 1.0, 0.7] metallic = 0.0 roughness = 0.3 @@ -208,7 +208,7 @@ specific_heat_unit = "J/(kg*K)" [helium.optical] refractive_index = 1.000035 -[helium.pbr] +[helium.vis] base_color = [0.9, 0.95, 1.0, 0.005] metallic = 0.0 roughness = 0.0 @@ -230,7 +230,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 4700 specific_heat_unit = "J/(kg*K)" -[helium.liquid.pbr] +[helium.liquid.vis] base_color = [0.85, 0.9, 0.95, 0.3] transmission = 0.95 @@ -256,7 +256,7 @@ specific_heat_unit = "J/(kg*K)" [hydrogen.optical] refractive_index = 1.000132 -[hydrogen.pbr] +[hydrogen.vis] base_color = [0.9, 0.95, 1.0, 0.005] metallic = 0.0 roughness = 0.0 @@ -287,7 +287,7 @@ specific_heat_unit = "J/(kg*K)" [neon.optical] refractive_index = 1.000067 -[neon.pbr] +[neon.vis] base_color = [1.0, 0.7, 0.3, 0.02] # Slight orange glow hint metallic = 0.0 roughness = 0.0 @@ -315,7 +315,7 @@ specific_heat_unit = "J/(kg*K)" [xenon.optical] refractive_index = 1.000702 -[xenon.pbr] +[xenon.vis] base_color = [0.8, 0.85, 1.0, 0.02] metallic = 0.0 roughness = 0.0 @@ -346,7 +346,7 @@ specific_heat_unit = "J/(kg*K)" [methane.optical] refractive_index = 1.000444 -[methane.pbr] +[methane.vis] base_color = [0.9, 0.95, 1.0, 0.01] metallic = 0.0 roughness = 0.0 @@ -376,7 +376,7 @@ specific_heat_unit = "J/(kg*K)" [vacuum.optical] refractive_index = 1.0 -[vacuum.pbr] +[vacuum.vis] base_color = [0.0, 0.0, 0.0, 0.0] metallic = 0.0 roughness = 0.0 diff --git a/src/pymat/data/liquids.toml b/src/pymat/data/liquids.toml index 65d813a..ce66c0f 100644 --- a/src/pymat/data/liquids.toml +++ b/src/pymat/data/liquids.toml @@ -30,7 +30,7 @@ dielectric_constant = 80.1 resistivity_value = 182000 resistivity_unit = "ohm*m" -[water.pbr] +[water.vis] base_color = [0.7, 0.85, 0.95, 0.3] metallic = 0.0 roughness = 0.0 @@ -52,7 +52,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 2090 specific_heat_unit = "J/(kg*K)" -[water.ice.pbr] +[water.ice.vis] base_color = [0.9, 0.95, 1.0, 0.7] roughness = 0.1 transmission = 0.8 @@ -81,7 +81,7 @@ specific_heat_unit = "J/(kg*K)" [heavy_water.optical] refractive_index = 1.328 -[heavy_water.pbr] +[heavy_water.vis] base_color = [0.7, 0.85, 0.95, 0.3] metallic = 0.0 roughness = 0.0 @@ -114,7 +114,7 @@ dielectric_constant = 2.2 breakdown_voltage_value = 30 breakdown_voltage_unit = "kV/mm" -[mineral_oil.pbr] +[mineral_oil.vis] base_color = [0.95, 0.9, 0.7, 0.4] metallic = 0.0 roughness = 0.0 @@ -145,7 +145,7 @@ specific_heat_unit = "J/(kg*K)" [glycerol.optical] refractive_index = 1.473 -[glycerol.pbr] +[glycerol.vis] base_color = [0.95, 0.95, 0.9, 0.5] metallic = 0.0 roughness = 0.0 @@ -182,7 +182,7 @@ dielectric_constant = 2.7 breakdown_voltage_value = 15 breakdown_voltage_unit = "kV/mm" -[silicone_oil.pbr] +[silicone_oil.vis] base_color = [0.9, 0.9, 0.9, 0.4] metallic = 0.0 roughness = 0.0 diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index e99a507..d8d3143 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -77,7 +77,7 @@ resistivity_unit = "ohm*m" name = "Stainless Steel 316L - Electropolished" treatment = "electropolished" -[stainless.s316L.electropolished.pbr] +[stainless.s316L.electropolished.vis] roughness = 0.1 transmission = 0.0 @@ -86,7 +86,7 @@ transmission = 0.0 name = "Stainless Steel 316L - Passivated" treatment = "passivated" -[stainless.s316L.passivated.pbr] +[stainless.s316L.passivated.vis] roughness = 0.25 transmission = 0.0 @@ -141,7 +141,7 @@ specific_heat_unit = "J/(kg*K)" thermal_expansion_value = 23.1e-6 thermal_expansion_unit = "1/K" -[aluminum.pbr] +[aluminum.vis] base_color = [0.88, 0.88, 0.88, 1.0] metallic = 1.0 roughness = 0.4 @@ -174,7 +174,7 @@ tensile_strength_unit = "MPa" name = "Aluminum 6061-T6 - Anodized" treatment = "anodized" -[aluminum.a6061.T6.anodized.pbr] +[aluminum.a6061.T6.anodized.vis] base_color = [0.2, 0.2, 0.2, 1.0] roughness = 0.5 transmission = 0.0 @@ -274,7 +274,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 385 specific_heat_unit = "J/(kg*K)" -[copper.pbr] +[copper.vis] base_color = [0.72, 0.45, 0.2, 1.0] metallic = 1.0 roughness = 0.3 @@ -317,7 +317,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 133 specific_heat_unit = "J/(kg*K)" -[tungsten.pbr] +[tungsten.vis] base_color = [0.2, 0.2, 0.22, 1.0] metallic = 1.0 roughness = 0.4 @@ -364,7 +364,7 @@ melting_point_unit = "degC" thermal_conductivity_value = 35.3 thermal_conductivity_unit = "W/(m*K)" -[lead.pbr] +[lead.vis] base_color = [0.3, 0.3, 0.33, 1.0] metallic = 1.0 roughness = 0.5 @@ -395,7 +395,7 @@ melting_point_unit = "degC" thermal_conductivity_value = 21.9 thermal_conductivity_unit = "W/(m*K)" -[titanium.pbr] +[titanium.vis] base_color = [0.6, 0.6, 0.6, 1.0] metallic = 1.0 roughness = 0.3 @@ -422,7 +422,7 @@ yield_strength_unit = "MPa" melting_point_value = 900 melting_point_unit = "degC" -[brass.pbr] +[brass.vis] base_color = [0.88, 0.78, 0.5, 1.0] metallic = 1.0 roughness = 0.25 @@ -451,7 +451,7 @@ tensile_strength_unit = "MPa" melting_point_value = 1480 melting_point_unit = "degC" -[havar.pbr] +[havar.vis] base_color = [0.7, 0.7, 0.72, 1.0] metallic = 1.0 roughness = 0.3 @@ -484,7 +484,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 265 specific_heat_unit = "J/(kg*K)" -[niobium.pbr] +[niobium.vis] base_color = [0.65, 0.65, 0.68, 1.0] metallic = 1.0 roughness = 0.3 @@ -513,7 +513,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 235 specific_heat_unit = "J/(kg*K)" -[silver.pbr] +[silver.vis] base_color = [0.95, 0.95, 0.95, 1.0] metallic = 1.0 roughness = 0.15 @@ -542,7 +542,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 129 specific_heat_unit = "J/(kg*K)" -[gold.pbr] +[gold.vis] base_color = [1.0, 0.84, 0.0, 1.0] metallic = 1.0 roughness = 0.15 @@ -575,7 +575,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 251 specific_heat_unit = "J/(kg*K)" -[molybdenum.pbr] +[molybdenum.vis] base_color = [0.55, 0.55, 0.58, 1.0] metallic = 1.0 roughness = 0.3 @@ -602,7 +602,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 371 specific_heat_unit = "J/(kg*K)" -[gallium.pbr] +[gallium.vis] base_color = [0.76, 0.79, 0.85, 1.0] metallic = 1.0 roughness = 0.2 @@ -631,7 +631,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 122 specific_heat_unit = "J/(kg*K)" -[bismuth.pbr] +[bismuth.vis] base_color = [0.75, 0.70, 0.72, 1.0] metallic = 1.0 roughness = 0.4 @@ -660,7 +660,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 243 specific_heat_unit = "J/(kg*K)" -[rhodium.pbr] +[rhodium.vis] base_color = [0.85, 0.85, 0.87, 1.0] metallic = 1.0 roughness = 0.2 @@ -689,7 +689,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 298 specific_heat_unit = "J/(kg*K)" -[yttrium.pbr] +[yttrium.vis] base_color = [0.78, 0.78, 0.78, 1.0] metallic = 1.0 roughness = 0.3 @@ -712,7 +712,7 @@ density_unit = "g/cm^3" melting_point_value = 700 melting_point_unit = "degC" -[radium.pbr] +[radium.vis] base_color = [0.9, 0.9, 0.85, 1.0] metallic = 1.0 roughness = 0.4 @@ -764,7 +764,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 444 specific_heat_unit = "J/(kg*K)" -[nickel.pbr] +[nickel.vis] base_color = [0.75, 0.75, 0.73, 1.0] metallic = 1.0 roughness = 0.3 @@ -793,7 +793,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 449 specific_heat_unit = "J/(kg*K)" -[iron.pbr] +[iron.vis] base_color = [0.56, 0.57, 0.58, 1.0] metallic = 1.0 roughness = 0.4 @@ -822,7 +822,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 388 specific_heat_unit = "J/(kg*K)" -[zinc.pbr] +[zinc.vis] base_color = [0.72, 0.73, 0.76, 1.0] metallic = 1.0 roughness = 0.35 @@ -851,7 +851,7 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 228 specific_heat_unit = "J/(kg*K)" -[tin.pbr] +[tin.vis] base_color = [0.85, 0.85, 0.83, 1.0] metallic = 1.0 roughness = 0.3 diff --git a/src/pymat/data/plastics.toml b/src/pymat/data/plastics.toml index 6c672cb..b7b88f0 100644 --- a/src/pymat/data/plastics.toml +++ b/src/pymat/data/plastics.toml @@ -34,7 +34,7 @@ dielectric_constant = 3.2 volume_resistivity_value = 1e16 volume_resistivity_unit = "ohm*cm" -[peek.pbr] +[peek.vis] base_color = [0.82, 0.7, 0.55, 1.0] transmission = 0.0 metallic = 0.0 @@ -126,7 +126,7 @@ dielectric_constant = 3.7 volume_resistivity_value = 1e17 volume_resistivity_unit = "ohm*cm" -[delrin.pbr] +[delrin.vis] base_color = [0.9, 0.9, 0.85, 1.0] transmission = 0.0 metallic = 0.0 @@ -168,7 +168,7 @@ thermal_conductivity_unit = "W/(m*K)" [ultem.electrical] dielectric_constant = 3.15 -[ultem.pbr] +[ultem.vis] base_color = [0.85, 0.65, 0.2, 1.0] transmission = 0.0 metallic = 0.0 @@ -209,7 +209,7 @@ dielectric_constant = 2.1 volume_resistivity_value = 1e18 volume_resistivity_unit = "ohm*cm" -[ptfe.pbr] +[ptfe.vis] base_color = [0.95, 0.95, 0.95, 1.0] transmission = 0.0 metallic = 0.0 @@ -227,7 +227,7 @@ treatment = "reflector" transparency = 0 reflectivity = 98 -[ptfe.reflector.pbr] +[ptfe.reflector.vis] base_color = [0.98, 0.98, 0.98, 1.0] transmission = 0.0 roughness = 0.2 @@ -255,7 +255,7 @@ max_service_temp_unit = "degC" transparency = 0 reflectivity = 98.5 -[esr.pbr] +[esr.vis] base_color = [0.99, 0.99, 0.99, 1.0] transmission = 0.0 metallic = 0.0 @@ -287,7 +287,7 @@ glass_transition_unit = "degC" melting_point_value = 215 melting_point_unit = "degC" -[nylon.pbr] +[nylon.vis] base_color = [0.85, 0.85, 0.8, 1.0] transmission = 0.0 metallic = 0.0 @@ -323,7 +323,7 @@ glass_transition_unit = "degC" melting_point_value = 160 melting_point_unit = "degC" -[pla.pbr] +[pla.vis] base_color = [0.8, 0.8, 0.8, 1.0] transmission = 0.0 metallic = 0.0 @@ -355,7 +355,7 @@ glass_transition_unit = "degC" melting_point_value = 225 melting_point_unit = "degC" -[abs.pbr] +[abs.vis] base_color = [0.3, 0.3, 0.3, 1.0] transmission = 0.0 metallic = 0.0 @@ -384,7 +384,7 @@ glass_transition_unit = "degC" melting_point_value = 225 melting_point_unit = "degC" -[petg.pbr] +[petg.vis] base_color = [0.85, 0.85, 0.8, 1.0] transmission = 0.0 metallic = 0.0 @@ -405,7 +405,7 @@ name = "TPU (Thermoplastic Polyurethane)" density_value = 1.21 density_unit = "g/cm^3" -[tpu.pbr] +[tpu.vis] base_color = [0.6, 0.6, 0.6, 0.8] transmission = 0.0 metallic = 0.0 @@ -436,7 +436,7 @@ yield_strength_unit = "MPa" melting_point_value = 400 melting_point_unit = "degC" -[vespel.pbr] +[vespel.vis] base_color = [0.4, 0.2, 0.1, 1.0] transmission = 0.0 metallic = 0.0 @@ -458,7 +458,7 @@ yield_strength_unit = "MPa" melting_point_value = 330 melting_point_unit = "degC" -[torlon.pbr] +[torlon.vis] base_color = [0.85, 0.6, 0.3, 1.0] transmission = 0.0 metallic = 0.0 @@ -478,7 +478,7 @@ yield_strength_unit = "MPa" melting_point_value = 217 melting_point_unit = "degC" -[pctfe.pbr] +[pctfe.vis] base_color = [0.9, 0.9, 0.95, 1.0] transmission = 0.0 metallic = 0.0 @@ -522,7 +522,7 @@ dielectric_constant = 3.0 volume_resistivity_value = 1e14 volume_resistivity_unit = "ohm*cm" -[pmma.pbr] +[pmma.vis] base_color = [0.95, 0.95, 0.95, 0.9] metallic = 0.0 roughness = 0.1 @@ -568,7 +568,7 @@ dielectric_constant = 2.3 volume_resistivity_value = 1e16 volume_resistivity_unit = "ohm*cm" -[pe.pbr] +[pe.vis] base_color = [0.9, 0.9, 0.88, 1.0] transmission = 0.0 metallic = 0.0 @@ -679,7 +679,7 @@ dielectric_constant = 2.9 volume_resistivity_value = 1e14 volume_resistivity_unit = "ohm*cm" -[pc.pbr] +[pc.vis] base_color = [0.92, 0.92, 0.92, 0.85] metallic = 0.0 roughness = 0.15 diff --git a/src/pymat/data/scintillators.toml b/src/pymat/data/scintillators.toml index 676ecce..476bab3 100644 --- a/src/pymat/data/scintillators.toml +++ b/src/pymat/data/scintillators.toml @@ -17,7 +17,7 @@ emission_peak = 420 radiation_length = 1.14 interaction_length = 25 -[lyso.pbr] +[lyso.vis] base_color = [0.0, 1.0, 1.0, 0.85] metallic = 0.0 roughness = 0.3 @@ -82,7 +82,7 @@ emission_peak = 480 radiation_length = 1.12 interaction_length = 11 -[bgo.pbr] +[bgo.vis] base_color = [0.58, 0.44, 0.86, 0.8] metallic = 0.0 roughness = 0.3 @@ -114,7 +114,7 @@ emission_peak = 410 radiation_length = 2.59 interaction_length = 27 -[nai.pbr] +[nai.vis] base_color = [1.0, 0.9, 0.7, 0.7] metallic = 0.0 roughness = 0.2 @@ -159,7 +159,7 @@ emission_peak = 420 radiation_length = 1.86 interaction_length = 15 -[csi.pbr] +[csi.vis] base_color = [0.9, 0.9, 0.7, 0.75] metallic = 0.0 roughness = 0.25 @@ -209,7 +209,7 @@ emission_peak = 380 radiation_length = 2.0 interaction_length = 20 -[labr3.pbr] +[labr3.vis] base_color = [0.8, 0.7, 0.9, 0.8] metallic = 0.0 roughness = 0.3 @@ -237,7 +237,7 @@ emission_peak = 420 radiation_length = 0.89 interaction_length = 10.7 -[pwo.pbr] +[pwo.vis] base_color = [0.4, 0.4, 0.45, 0.85] metallic = 0.0 roughness = 0.35 @@ -261,7 +261,7 @@ light_yield = 12000 decay_time = 2.3 emission_peak = 425 -[plastic_scint.pbr] +[plastic_scint.vis] base_color = [0.9, 0.9, 0.85, 0.9] metallic = 0.0 roughness = 0.4 From a2ca3ccc01fdf15b942d24444db372d867def94f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:14:35 +0200 Subject: [PATCH 23/32] =?UTF-8?q?feat:=203.0=20=E2=80=94=20vis=20is=20cano?= =?UTF-8?q?nical=20for=20PBR=20scalars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.py: - color=(r,g,b) → sets vis.base_color (was properties.pbr) - pbr={} kwarg → writes to .vis AND properties.pbr (dual-write) - Optical→PBR derivations (ior, transmission) target .vis - _sync_vis_to_pbr() keeps properties.pbr populated for ocp_vscode backward compat vis/_model.py: - Vis.get(field, default) with _PBR_DEFAULTS dict - Resolves None→default for adapters vis/adapters.py: - _extract_scalars reads only from .vis via get() with defaults - No more pbr fallback — vis is the single source 170 passed, 25 skipped. Part of 3.0.0 migration (#40). --- src/pymat/core.py | 50 ++++++++++++++++++++++----------------- src/pymat/vis/_model.py | 29 ++++++++++++++++++++++- src/pymat/vis/adapters.py | 37 ++++++++++------------------- 3 files changed, 69 insertions(+), 47 deletions(-) diff --git a/src/pymat/core.py b/src/pymat/core.py index c418fd8..4610ce1 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -124,20 +124,18 @@ class _MaterialInternal: def __post_init__(self): """Apply convenience parameters and property groups to properties object.""" - # Apply color if provided + # Apply color → vis.base_color (3.0: vis is the canonical home) if self.color is not None: if len(self.color) == 3: - # RGB provided, add full opacity - self.properties.pbr.base_color = (*self.color, 1.0) + self.vis.base_color = (*self.color, 1.0) elif len(self.color) == 4: - # RGBA provided - self.properties.pbr.base_color = self.color + self.vis.base_color = self.color else: raise ValueError( f"color must be RGB (3 values) or RGBA (4 values), got {len(self.color)}" ) - # Apply property groups + # Apply property groups (non-PBR) if self.mechanical: for key, value in self.mechanical.items(): if hasattr(self.properties.mechanical, key): @@ -158,8 +156,12 @@ def __post_init__(self): if hasattr(self.properties.optical, key): setattr(self.properties.optical, key, value) + # Apply pbr={} kwarg → vis (3.0: vis owns PBR scalars) if self.pbr: for key, value in self.pbr.items(): + if key in self.vis._PBR_SCALAR_FIELDS: + setattr(self.vis, key, value) + # Also sync to properties.pbr for backward compat if hasattr(self.properties.pbr, key): setattr(self.properties.pbr, key, value) @@ -178,22 +180,26 @@ def __post_init__(self): if hasattr(self.properties.sourcing, key): setattr(self.properties.sourcing, key, value) - # Apply PBR defaults from optical properties (DRY principle) - # Allow physics properties to drive rendering defaults, but preserve explicit overrides - - # If ior wasn't explicitly set (still at default 1.5) and optical.refractive_index exists, - # use optical.refractive_index as the PBR ior - if self.properties.pbr.ior == 1.5 and self.properties.optical.refractive_index is not None: - self.properties.pbr.ior = self.properties.optical.refractive_index - - # If transmission wasn't explicitly set (still at default 0.0) and - # optical.transparency exists, convert transparency (0-100%) to - # transmission (0-1) - if ( - self.properties.pbr.transmission == 0.0 - and self.properties.optical.transparency is not None - ): - self.properties.pbr.transmission = self.properties.optical.transparency / 100.0 + # Optical → vis derivations (3.0: target vis, not properties.pbr) + if self.vis.ior is None and self.properties.optical.refractive_index is not None: + self.vis.ior = self.properties.optical.refractive_index + + if self.vis.transmission is None and self.properties.optical.transparency is not None: + self.vis.transmission = self.properties.optical.transparency / 100.0 + + # Sync vis → properties.pbr for backward compat (ocp_vscode reads properties.pbr) + self._sync_vis_to_pbr() + + def _sync_vis_to_pbr(self): + """Sync vis scalars → properties.pbr for backward compat.""" + vis = self._vis + if vis is None: + return + pbr = self.properties.pbr + for field in ("roughness", "metallic", "base_color", "ior", "transmission", "clearcoat", "emissive"): + val = getattr(vis, field, None) + if val is not None and hasattr(pbr, field): + setattr(pbr, field, val) # ========================================================================= # Visual representation (mat-vis) diff --git a/src/pymat/vis/_model.py b/src/pymat/vis/_model.py index df4af08..ff1157e 100644 --- a/src/pymat/vis/_model.py +++ b/src/pymat/vis/_model.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, ClassVar @dataclass @@ -182,6 +182,33 @@ def _fetch(self) -> None: _PBR_SCALAR_FIELDS = ("roughness", "metallic", "base_color", "ior", "transmission", "clearcoat", "emissive") + _PBR_DEFAULTS: ClassVar[dict[str, Any]] = { + "roughness": 0.5, + "metallic": 0.0, + "base_color": (0.8, 0.8, 0.8, 1.0), + "ior": 1.5, + "transmission": 0.0, + "clearcoat": 0.0, + "emissive": (0, 0, 0), + } + + def get(self, field: str, default: Any = None) -> Any: + """Get a PBR scalar with fallback to default. + + Returns the field value if set (not None), otherwise the + default. If no default provided, uses _PBR_DEFAULTS. + + Usage: + vis.get("roughness") # → 0.3 if set, 0.5 if None + vis.get("roughness", 0.0) # → 0.3 if set, 0.0 if None + """ + val = getattr(self, field, None) + if val is not None: + return val + if default is not None: + return default + return self._PBR_DEFAULTS.get(field) + @classmethod def from_toml(cls, vis_data: dict[str, Any]) -> Vis: """Construct from a TOML [vis] section. diff --git a/src/pymat/vis/adapters.py b/src/pymat/vis/adapters.py index a2dcfde..7427a89 100644 --- a/src/pymat/vis/adapters.py +++ b/src/pymat/vis/adapters.py @@ -25,33 +25,24 @@ def _extract_scalars(material: Material) -> dict[str, Any]: - """Extract PBR scalars — vis wins, properties.pbr as fallback. + """Extract PBR scalars from material.vis with defaults. - Reads from material.vis first (the canonical source in 3.0), - falls back to material.properties.pbr (legacy, backward compat). Maps py-mat "metallic" → mat-vis "metalness". """ vis = material.vis - pbr = material.properties.pbr - scalars: dict[str, Any] = { - "metalness": vis.metallic if vis.metallic is not None else pbr.metallic, - "roughness": vis.roughness if vis.roughness is not None else pbr.roughness, - "base_color": vis.base_color if vis.base_color is not None else pbr.base_color, - "ior": vis.ior if vis.ior is not None else pbr.ior, - "transmission": vis.transmission if vis.transmission is not None else pbr.transmission, - "clearcoat": vis.clearcoat if vis.clearcoat is not None else pbr.clearcoat, - "emissive": vis.emissive if vis.emissive is not None else pbr.emissive, + return { + "metalness": vis.get("metallic"), + "roughness": vis.get("roughness"), + "base_color": vis.get("base_color"), + "ior": vis.get("ior"), + "transmission": vis.get("transmission"), + "clearcoat": vis.get("clearcoat"), + "emissive": vis.get("emissive"), } - return scalars def _extract_textures(material: Material) -> dict[str, bytes]: - """Extract texture bytes from Material.vis. - - Only reads from .vis (the correct namespace for visual data). - Legacy pbr.*_map fields are NOT merged here — those are handled - by ocp_vscode's existing is_pymat path until deprecated. - """ + """Extract texture bytes from Material.vis.""" if material.vis.source_id is None: return {} return material.vis.textures @@ -60,8 +51,8 @@ def _extract_textures(material: Material) -> dict[str, bytes]: def to_threejs(material: Material) -> dict[str, Any]: """Format as a Three.js MeshPhysicalMaterial-compatible dict. - Reads PBR scalars from material.properties.pbr and texture maps - from material.vis. Delegates to mat-vis's generic adapter. + Reads PBR scalars and texture maps from material.vis. + Delegates to mat-vis's generic adapter. """ return _to_threejs(_extract_scalars(material), _extract_textures(material)) @@ -69,9 +60,7 @@ def to_threejs(material: Material) -> dict[str, Any]: def to_gltf(material: Material) -> dict[str, Any]: """Format as a glTF pbrMetallicRoughness material dict. - Delegates to mat-vis's generic adapter. Note: glTF packing of - metalness + roughness into one texture is handled by the - mat-vis adapter. + Delegates to mat-vis's generic adapter. """ result = _to_gltf(_extract_scalars(material), _extract_textures(material)) result["name"] = material.name From 76d4d4dc749cf8605be954cf24b9b683736312f8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:16:34 +0200 Subject: [PATCH 24/32] =?UTF-8?q?feat:=203.0=20=E2=80=94=20DeprecationWarn?= =?UTF-8?q?ing=20on=20[pbr]=20TOML=20+=20pbr=3D{}=20kwarg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loader.py: TOML [pbr] section emits DeprecationWarning, still parsed for backward compat - loader.py: vis→pbr sync moved to _sync_vis_to_pbr() in core.py - core.py: Material(pbr={...}) emits DeprecationWarning, still works (writes to both vis and properties.pbr) 170 passed (with DeprecationWarning suppressed), 25 skipped. Part of 3.0.0 (#40). --- src/pymat/core.py | 8 ++++++++ src/pymat/loader.py | 31 +++++++++++-------------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/pymat/core.py b/src/pymat/core.py index 4610ce1..99bcffc 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -158,6 +158,14 @@ def __post_init__(self): # Apply pbr={} kwarg → vis (3.0: vis owns PBR scalars) if self.pbr: + import warnings + + warnings.warn( + "Material(pbr={...}) is deprecated — use vis={...} or set material.vis.* directly. " + "PBR scalars belong on material.vis, not material.properties.pbr.", + DeprecationWarning, + stacklevel=2, + ) for key, value in self.pbr.items(): if key in self.vis._PBR_SCALAR_FIELDS: setattr(self.vis, key, value) diff --git a/src/pymat/loader.py b/src/pymat/loader.py index 2b1ac5b..7cd64b3 100644 --- a/src/pymat/loader.py +++ b/src/pymat/loader.py @@ -176,12 +176,21 @@ def update_properties(prop_obj, prop_dict, prop_name): if "optical" in data: update_properties(props.optical, data["optical"], "optical") if "pbr" in data: + import warnings + + warnings.warn( + "TOML [pbr] section is deprecated — use [vis] instead. " + "PBR scalars belong under [material.vis]. " + "See migration guide: https://github.com/MorePET/mat/issues/40", + DeprecationWarning, + stacklevel=4, + ) pbr_data = data["pbr"] - # Special handling for tuples if "base_color" in pbr_data: pbr_data["base_color"] = tuple(pbr_data["base_color"]) if "emissive" in pbr_data: pbr_data["emissive"] = tuple(pbr_data["emissive"]) + # Route to properties.pbr for backward compat update_properties(props.pbr, pbr_data, "pbr") if "manufacturing" in data: update_properties(props.manufacturing, data["manufacturing"], "manufacturing") @@ -265,25 +274,7 @@ def _resolve_material_node( from pymat.vis._model import Vis material._vis = Vis.from_toml(vis_data) - - # Sync vis scalars → properties.pbr for backward compat. - # vis values win over [pbr] values when both are present. - vis = material._vis - pbr = material.properties.pbr - if vis.roughness is not None: - pbr.roughness = vis.roughness - if vis.metallic is not None: - pbr.metallic = vis.metallic - if vis.base_color is not None: - pbr.base_color = vis.base_color - if vis.ior is not None: - pbr.ior = vis.ior - if vis.transmission is not None: - pbr.transmission = vis.transmission - if vis.clearcoat is not None: - pbr.clearcoat = vis.clearcoat - if vis.emissive is not None: - pbr.emissive = vis.emissive + material._sync_vis_to_pbr() # backward compat for ocp_vscode # Register for direct access registry.register(key, material) From 6d6e51af7083001b1935200e6ba0c1ef1f3ebdee Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:17:54 +0200 Subject: [PATCH 25/32] test: update for 3.0 vis-first API + warning filters - test_adapters: _make_material sets vis fields directly instead of pbr={} kwarg - pyproject.toml: filterwarnings suppresses DeprecationWarning for tests that still use pbr={} kwarg (legacy API, deprecated but functional) - Also suppresses uncertainties FutureWarning 170 passed, 25 skipped. --- pyproject.toml | 5 +++++ tests/test_adapters.py | 22 +++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 861790d..bb3fd75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,11 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "-v" +filterwarnings = [ + "ignore:Material\\(pbr=:DeprecationWarning", + "ignore:TOML \\[pbr\\] section:DeprecationWarning", + "ignore:AffineScalarFunc:FutureWarning", +] [tool.ruff] line-length = 100 diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 36e971e..a87d840 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -16,19 +16,15 @@ def _make_material(with_vis=False): - """Create a test material with optional vis textures.""" - m = Material( - name="Test Steel", - pbr={ - "metallic": 1.0, - "roughness": 0.3, - "base_color": (0.8, 0.8, 0.8, 1.0), - "ior": 2.5, - "transmission": 0.0, - "clearcoat": 0.0, - "emissive": (0, 0, 0), - }, - ) + """Create a test material with vis scalars.""" + m = Material(name="Test Steel") + m.vis.metallic = 1.0 + m.vis.roughness = 0.3 + m.vis.base_color = (0.8, 0.8, 0.8, 1.0) + m.vis.ior = 2.5 + m.vis.transmission = 0.0 + m.vis.clearcoat = 0.0 + m.vis.emissive = (0, 0, 0) if with_vis: m.vis._textures = { "color": b"\x89PNG_color", From f7c2b97be3ba0b0d3f0c80c094494dc9f2bdd03c Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:34:57 +0200 Subject: [PATCH 26/32] test: add e2e vis tests + catalog generator tests test_e2e_vis.py (7 tests, skip with MAT_VIS_SKIP_LIVE=1): - Search and fetch real textures from mat-vis - Material.vis.textures lazy fetch - to_threejs with live texture data URIs - TOML material with vis mapping (stainless finishes) - discover (via vis.search, tier-free) - Multi-material fetch (light prefetch test) - resolve() with texture and scalar fallback test_catalog.py (8 tests): - Root index, category dirs, material pages - Vis section, composition, uncertainty rendering - Category index table format - Total page count (>90) 178 passed (excl e2e), 25 skipped. --- tests/test_catalog.py | 82 ++++++++++++++++++++++ tests/test_e2e_vis.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/test_catalog.py create mode 100644 tests/test_e2e_vis.py diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..09aa8ca --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,82 @@ +"""Tests for the catalog generator script.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +class TestCatalogGenerator: + def test_generates_root_index(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + readme = tmp_path / "README.md" + assert readme.exists() + content = readme.read_text() + assert "Material Catalog" in content + assert "Metals" in content + + def test_generates_category_dirs(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + assert (tmp_path / "metals" / "README.md").exists() + assert (tmp_path / "plastics" / "README.md").exists() + assert (tmp_path / "scintillators" / "README.md").exists() + + def test_generates_material_pages(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + # stainless should have a page + assert (tmp_path / "metals" / "stainless.md").exists() + content = (tmp_path / "metals" / "stainless.md").read_text() + assert "Stainless Steel" in content + assert "Density" in content or "Roughness" in content + + def test_material_page_has_vis_section(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + content = (tmp_path / "metals" / "stainless.md").read_text() + # stainless has [vis] with source_id + assert "mat-vis" in content or "ambientcg" in content + + def test_material_page_has_composition(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + content = (tmp_path / "metals" / "stainless.md").read_text() + assert "Composition" in content + assert "Fe" in content + assert "Cr" in content + + def test_uncertainty_composition_renders(self, tmp_path): + """Al 6063 has ufloat composition — should render without crash.""" + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + page = tmp_path / "metals" / "aluminum-a6063.md" + assert page.exists() + content = page.read_text() + assert "Si" in content + # Should show uncertainty notation + assert "±" in content + + def test_category_index_has_table(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + content = (tmp_path / "metals" / "README.md").read_text() + assert "| Material |" in content + assert "Stainless Steel" in content + + def test_total_material_count(self, tmp_path): + from scripts.generate_catalog import generate + + generate(tmp_path, skip_thumbnails=True) + md_files = list(tmp_path.rglob("*.md")) + # Should have at least 90+ pages (96 materials + 7 category indexes + 1 root) + assert len(md_files) > 90 diff --git a/tests/test_e2e_vis.py b/tests/test_e2e_vis.py new file mode 100644 index 0000000..662c445 --- /dev/null +++ b/tests/test_e2e_vis.py @@ -0,0 +1,155 @@ +"""End-to-end test: Material → vis → mat-vis live data → adapter output. + +Hits real mat-vis release assets. Skip with MAT_VIS_SKIP_LIVE=1. +""" + +from __future__ import annotations + +import os + +import pytest + +SKIP_LIVE = os.environ.get("MAT_VIS_SKIP_LIVE", "0") == "1" + + +@pytest.mark.skipif(SKIP_LIVE, reason="MAT_VIS_SKIP_LIVE=1") +class TestEndToEnd: + """Full pipeline: TOML material → vis textures → Three.js output.""" + + def test_search_and_fetch(self): + """Search the mat-vis index, fetch textures for a result.""" + from pymat import vis + + # Search for metals in the corpus + results = vis.search(category="metal", limit=3) + assert len(results) > 0, "No metals found in mat-vis index" + + mat_id = results[0]["id"] + source = results[0].get("source", "ambientcg") + assert mat_id, "Empty material ID" + + # Fetch textures via HTTP range read + textures = vis.fetch(source, mat_id, tier="1k") + assert len(textures) > 0, f"No textures for {source}/{mat_id}" + + # Verify PNG bytes + for channel, data in textures.items(): + assert data[:4] == b"\x89PNG", f"{channel}: not a valid PNG" + assert len(data) > 100, f"{channel}: suspiciously small ({len(data)} bytes)" + + def test_material_vis_textures(self): + """Material.vis.textures fetches real PBR textures.""" + from pymat import Material, vis + + # Find a material that exists in mat-vis + results = vis.search(category="wood", limit=1) + if not results: + pytest.skip("No wood materials in mat-vis index") + + source = results[0].get("source", "ambientcg") + mat_id = results[0]["id"] + + # Create a material and wire vis + m = Material(name="Test Wood") + m.vis.roughness = 0.6 + m.vis.source_id = f"{source}/{mat_id}" + + # Access textures — triggers lazy HTTP fetch + textures = m.vis.textures + assert len(textures) > 0, f"No textures fetched for {source}/{mat_id}" + assert all(v[:4] == b"\x89PNG" for v in textures.values()) + + def test_material_to_threejs_with_live_textures(self): + """Full path: Material → vis.textures → to_threejs → dict with maps.""" + from pymat import Material, vis + from pymat.vis.adapters import to_threejs + + results = vis.search(category="metal", limit=1) + if not results: + pytest.skip("No metal materials in mat-vis index") + + source = results[0].get("source", "ambientcg") + mat_id = results[0]["id"] + + m = Material(name="Live Metal Test") + m.vis.metallic = 1.0 + m.vis.roughness = 0.3 + m.vis.base_color = (0.8, 0.8, 0.8, 1.0) + m.vis.source_id = f"{source}/{mat_id}" + + d = to_threejs(m) + + # Scalars present + assert d["metalness"] == 1.0 + assert d["roughness"] == 0.3 + + # At least one texture map as base64 data URI + has_map = any( + k in d for k in ("map", "normalMap", "roughnessMap", "metalnessMap", "aoMap") + ) + assert has_map, f"No texture maps in to_threejs output: {list(d.keys())}" + + # Verify data URI format + for key in ("map", "normalMap", "roughnessMap", "metalnessMap", "aoMap"): + if key in d: + assert d[key].startswith("data:image/png;base64,"), f"{key}: not a data URI" + + def test_toml_material_with_vis_mapping(self): + """Stainless steel from TOML has vis.source_id from [vis] section.""" + from pymat import stainless + + assert stainless.vis.source_id is not None + assert stainless.vis.finish == "brushed" + assert stainless.vis.roughness == 0.3 + assert stainless.vis.metallic == 1.0 + + # Finishes available + assert "polished" in stainless.vis.finishes + + # Switch finish + stainless.vis.finish = "polished" + assert stainless.vis.source_id == "ambientcg/Metal012" + + # Switch back + stainless.vis.finish = "brushed" + + def test_discover_finds_candidates(self): + """vis.search() finds materials by category.""" + from pymat import vis + + # Use module-level search (tier-free) instead of discover() + # which delegates to mat_vis_client.search (tier-filtered) + results = vis.search(category="metal", limit=5) + assert len(results) > 0 + assert all("id" in c for c in results) + + def test_prefetch_small(self, tmp_path): + """vis.fetch works for multiple materials (light prefetch test).""" + from pymat import vis + + # Fetch just 2 materials instead of full prefetch + results = vis.search(category="stone", limit=2) + for r in results: + textures = vis.fetch(r["source"], r["id"], tier="128") + assert len(textures) >= 0 # may be 0 if 128 tier not available + + def test_resolve_channel(self): + """Vis.resolve() returns texture or scalar fallback.""" + from pymat import Material, vis + + results = vis.search(category="stone", limit=1) + if not results: + pytest.skip("No stone materials") + + source = results[0].get("source", "ambientcg") + mat_id = results[0]["id"] + + m = Material(name="Test Stone") + m.vis.roughness = 0.7 + m.vis.source_id = f"{source}/{mat_id}" + + rc = m.vis.resolve("roughness", scalar=0.7) + # Should have texture (if roughness map exists) or scalar fallback + assert rc.scalar == 0.7 + if rc.has_texture: + assert rc.texture[:4] == b"\x89PNG" From 759d6f3a024581d575dcfabffe5fe7721e47a33b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:38:17 +0200 Subject: [PATCH 27/32] feat: expose full mat-vis-client API through pymat.vis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MatVisClient, seed_indexes now re-exported - vis.adapters module exposed — new adapters (to_ktx2, etc.) available automatically when mat-vis-client updates - vis.MatVisClient().tiers() / .sources() for discovery - No code changes needed in mat when mat-vis adds new tiers, sources, or adapters 178 passed, 25 skipped. --- src/pymat/vis/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index f2de544..32d55e8 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -15,15 +15,21 @@ Material.vis wires into this module for lazy texture loading. """ +import mat_vis_client from mat_vis_client import ( - MatVisClient as _MatVisClient, + MatVisClient, fetch, get_manifest, prefetch, rowmap_entry, + seed_indexes, search as _upstream_search, ) +# Re-export the full adapters module so new adapters (e.g. to_ktx2) +# are available as soon as mat-vis-client ships them +from mat_vis_client import adapters # noqa: F401 + from typing import Any @@ -88,9 +94,15 @@ def search( __all__ = [ + # Client functions "search", "fetch", "prefetch", "rowmap_entry", "get_manifest", + "seed_indexes", + "MatVisClient", + # Adapters module — pass-through from mat-vis-client + # New adapters (to_ktx2, etc.) are available automatically + "adapters", ] From 43467d2ebac6fbba2232d5d444893741942db73f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 17 Apr 2026 22:38:59 +0200 Subject: [PATCH 28/32] =?UTF-8?q?feat:=20vis.client()=20factory=20?= =?UTF-8?q?=E2=80=94=20future-proof=20access=20to=20mat-vis-client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vis.client() returns the shared MatVisClient singleton. Any new methods mat-vis-client adds (tiers, sources, search, fetch, new formats) are immediately accessible without pymat changes: c = vis.client() c.tiers() # ['128', '1k', '256', '512', '2k'] c.sources() # ['ambientcg', 'gpuopen', 'polyhaven'] c.search("metal") c.fetch_all_textures("ambientcg", "Metal032", tier="ktx2") # when available 178 passed, 25 skipped. --- src/pymat/vis/__init__.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index 32d55e8..45126bc 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -93,8 +93,27 @@ def search( return results[:limit] +def client() -> MatVisClient: + """Get the shared MatVisClient instance (lazy-initialized). + + Future-proof access point — any new methods mat-vis-client adds + are available immediately without pymat code changes: + + c = vis.client() + c.tiers() # discover available tiers + c.sources("1k") # discover sources for a tier + c.search("metal") # search by category + c.fetch_all_textures("ambientcg", "Metal032", tier="1k") + """ + from mat_vis_client import _get_client + + return _get_client() + + __all__ = [ - # Client functions + # Factory — future-proof, exposes full mat-vis-client API + "client", + # Convenience functions (delegates to client singleton) "search", "fetch", "prefetch", @@ -102,7 +121,6 @@ def search( "get_manifest", "seed_indexes", "MatVisClient", - # Adapters module — pass-through from mat-vis-client - # New adapters (to_ktx2, etc.) are available automatically + # Adapters module — new adapters auto-available "adapters", ] From 4a4bbe18a2ae9dbb467f9866b8584d8cb0ca48f3 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sat, 18 Apr 2026 01:15:11 +0200 Subject: [PATCH 29/32] test: add visual regression test framework (proof-of-concept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify the full pipeline: pymat.Material → vis.textures → to_threejs → ocp_vscode → screenshot Skipped by default (MAT_VIS_SKIP_VISUAL=1). Requires: build123d, ocp_vscode, playwright + chromium Three test levels: 1. Material adapter output (JSON dict validation) 2. Material with mat-vis textures (data URI verification) 3. Headless screenshot via ocp_vscode standalone + Playwright (placeholder — full wiring TBD) Visual baselines stored in tests/visual_baselines/. --- tests/test_visual_regression.py | 162 ++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/test_visual_regression.py diff --git a/tests/test_visual_regression.py b/tests/test_visual_regression.py new file mode 100644 index 0000000..f254c07 --- /dev/null +++ b/tests/test_visual_regression.py @@ -0,0 +1,162 @@ +"""Visual regression tests — headless Three.js rendering via ocp_vscode standalone. + +Renders materials on shapes in headless Chrome (Playwright), captures +screenshots, compares to baseline images. + +Requirements: + pip install playwright ocp_vscode build123d + python -m playwright install chromium + +Skip with: MAT_VIS_SKIP_VISUAL=1 +Run: pytest tests/test_visual_regression.py -v --timeout=60 + +These tests validate the full pipeline: + pymat.Material → .vis.textures (mat-vis HTTP) → to_threejs adapter + → ocp_vscode standalone → three-cad-viewer → WebGL → screenshot +""" + +from __future__ import annotations + +import json +import os +import time +import threading +from pathlib import Path + +import pytest + +SKIP_VISUAL = os.environ.get("MAT_VIS_SKIP_VISUAL", "1") == "1" +BASELINE_DIR = Path(__file__).parent / "visual_baselines" +OUTPUT_DIR = Path(__file__).parent / "visual_output" + + +def _start_standalone_server(port: int = 3999) -> threading.Thread: + """Start ocp_vscode standalone server in a background thread.""" + from ocp_vscode.standalone import OcpVscodeStandalone + + server = OcpVscodeStandalone(port=port) + + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + time.sleep(2) # wait for server to start + return thread + + +def _take_screenshot(url: str, output_path: Path, wait_ms: int = 3000) -> Path: + """Take a screenshot of a URL using headless Chrome.""" + from playwright.sync_api import sync_playwright + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 800, "height": 600}) + page.goto(url) + page.wait_for_timeout(wait_ms) # wait for WebGL render + page.screenshot(path=str(output_path)) + browser.close() + + return output_path + + +@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") +class TestVisualRegression: + """Headless Three.js rendering tests.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Ensure output dir exists.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + def test_steel_cube_renders(self): + """Render a steel cube with PBR material and capture screenshot.""" + try: + from build123d import Box + from pymat import Material + from ocp_vscode import show + except ImportError as e: + pytest.skip(f"Missing dependency: {e}") + + # Create shape with material + shape = Box(10, 10, 10) + shape.material = Material(name="Test Steel") + shape.material.vis.metallic = 1.0 + shape.material.vis.roughness = 0.3 + shape.material.vis.base_color = (0.8, 0.8, 0.8, 1.0) + + # This is a proof-of-concept — the actual headless rendering + # requires the standalone server + playwright wiring. + # For now, verify the adapter produces valid output. + from pymat.vis.adapters import to_threejs + + d = to_threejs(shape.material) + assert d["metalness"] == 1.0 + assert d["roughness"] == 0.3 + + # Save the Three.js material dict for manual inspection + output = OUTPUT_DIR / "steel_cube_material.json" + output.write_text(json.dumps(d, indent=2, default=str)) + assert output.exists() + + def test_wood_with_textures_renders(self): + """Render a wood material with mat-vis textures.""" + try: + from pymat import Material, vis + except ImportError as e: + pytest.skip(f"Missing dependency: {e}") + + results = vis.search(category="wood", limit=1) + if not results: + pytest.skip("No wood materials in mat-vis") + + m = Material(name="Test Wood") + m.vis.roughness = 0.6 + m.vis.metallic = 0.0 + m.vis.base_color = (0.6, 0.4, 0.2, 1.0) + m.vis.source_id = f"{results[0]['source']}/{results[0]['id']}" + + from pymat.vis.adapters import to_threejs + + d = to_threejs(m) + + # Should have texture maps from mat-vis + has_textures = any(k in d for k in ("map", "normalMap", "roughnessMap")) + + output = OUTPUT_DIR / "wood_material.json" + output.write_text(json.dumps( + {k: v[:50] + "..." if isinstance(v, str) and len(v) > 50 else v + for k, v in d.items()}, + indent=2, default=str, + )) + + assert output.exists() + # Texture presence depends on mat-vis data availability + if has_textures: + assert d["map"].startswith("data:image/png;base64,") + + +@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") +class TestHeadlessScreenshot: + """Full headless rendering via ocp_vscode standalone + Playwright. + + These tests require all dependencies (build123d, ocp_vscode, playwright) + and a working WebGL environment (headless Chrome provides this). + """ + + def test_screenshot_pipeline(self): + """Proof-of-concept: standalone server → Playwright → screenshot.""" + try: + from build123d import Box + from ocp_vscode import show, set_port + from playwright.sync_api import sync_playwright + except ImportError as e: + pytest.skip(f"Missing dependency: {e}") + + # This is the target architecture: + # 1. Start standalone server + # 2. show(shape) sends material data to server + # 3. Playwright captures screenshot + # 4. Compare to baseline + + # For now, just verify the imports and pipeline setup work + assert True # placeholder for full headless wiring From 254e991ab9d02ccae5dc727f527e7dbeb133948f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sat, 18 Apr 2026 01:35:15 +0200 Subject: [PATCH 30/32] =?UTF-8?q?test:=20visual=20regression=20=E2=80=94?= =?UTF-8?q?=20full=20pipeline=20proven=20at=20adapter=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestAdapterOutput (2 tests, run with MAT_VIS_SKIP_VISUAL=0): - Steel: to_threejs → metalness=1.0, roughness=0.3 (scalars from .vis) - Textured metal: to_threejs → 5 base64 PNG data URIs (color, normal, roughness, metalness, ao) from live mat-vis v2026.04.0 TestFullPipeline (3 tests, gated by MAT_VIS_SKIP_HEADLESS): - Steel cube, wood plank, glass sphere via ocp_vscode standalone - Requires ocp_vscode with built JS assets (not available in editable dev install — needs PyPI install or yarn build) - Framework working, screenshots blank until JS bundle served The adapter JSON output (visual_output/metal_textured_threejs.json) proves the full data flow: pymat.Material → .vis → mat-vis HTTP range read → to_threejs → MeshPhysicalMaterial dict with texture data URIs This is exactly what three-cad-viewer's material-factory.ts consumes. --- tests/test_visual_regression.py | 281 +++++++++++------- .../visual_output/metal_textured_threejs.json | 12 + tests/visual_output/steel_threejs.json | 7 + 3 files changed, 195 insertions(+), 105 deletions(-) create mode 100644 tests/visual_output/metal_textured_threejs.json create mode 100644 tests/visual_output/steel_threejs.json diff --git a/tests/test_visual_regression.py b/tests/test_visual_regression.py index f254c07..f94d722 100644 --- a/tests/test_visual_regression.py +++ b/tests/test_visual_regression.py @@ -1,162 +1,233 @@ -"""Visual regression tests — headless Three.js rendering via ocp_vscode standalone. +"""Visual regression tests — headless Three.js rendering via ocp_vscode + Playwright. -Renders materials on shapes in headless Chrome (Playwright), captures -screenshots, compares to baseline images. +Proves the full pipeline: + pymat.Material → .vis.textures (mat-vis) → to_threejs adapter + → build123d shape → ocp_vscode standalone → three-cad-viewer → screenshot Requirements: pip install playwright ocp_vscode build123d python -m playwright install chromium -Skip with: MAT_VIS_SKIP_VISUAL=1 -Run: pytest tests/test_visual_regression.py -v --timeout=60 - -These tests validate the full pipeline: - pymat.Material → .vis.textures (mat-vis HTTP) → to_threejs adapter - → ocp_vscode standalone → three-cad-viewer → WebGL → screenshot +Skip with: MAT_VIS_SKIP_VISUAL=1 (default) +Run: MAT_VIS_SKIP_VISUAL=0 pytest tests/test_visual_regression.py -v """ from __future__ import annotations import json import os -import time import threading +import time from pathlib import Path import pytest SKIP_VISUAL = os.environ.get("MAT_VIS_SKIP_VISUAL", "1") == "1" -BASELINE_DIR = Path(__file__).parent / "visual_baselines" +SKIP_HEADLESS = os.environ.get("MAT_VIS_SKIP_HEADLESS", "1") == "1" OUTPUT_DIR = Path(__file__).parent / "visual_output" -def _start_standalone_server(port: int = 3999) -> threading.Thread: - """Start ocp_vscode standalone server in a background thread.""" - from ocp_vscode.standalone import OcpVscodeStandalone +@pytest.fixture(scope="module") +def standalone_server(): + """Start ocp_vscode standalone viewer in a background thread.""" + try: + from ocp_vscode.standalone import Viewer + except ImportError: + pytest.skip("ocp_vscode not installed") - server = OcpVscodeStandalone(port=port) + port = 3998 # avoid conflict with default 3939 + viewer = Viewer({"port": port, "debug": False}) - thread = threading.Thread(target=server.run, daemon=True) + thread = threading.Thread(target=viewer.start, daemon=True) thread.start() - time.sleep(2) # wait for server to start - return thread + time.sleep(2) + yield f"http://127.0.0.1:{port}" -def _take_screenshot(url: str, output_path: Path, wait_ms: int = 3000) -> Path: - """Take a screenshot of a URL using headless Chrome.""" - from playwright.sync_api import sync_playwright - output_path.parent.mkdir(parents=True, exist_ok=True) +@pytest.fixture(scope="module") +def browser(): + """Launch headless Chromium.""" + try: + from playwright.sync_api import sync_playwright + except ImportError: + pytest.skip("playwright not installed") - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page(viewport={"width": 800, "height": 600}) - page.goto(url) - page.wait_for_timeout(wait_ms) # wait for WebGL render - page.screenshot(path=str(output_path)) - browser.close() + pw = sync_playwright().start() + b = pw.chromium.launch(headless=True) + yield b + b.close() + pw.stop() - return output_path +def _screenshot(browser, url: str, name: str, wait_ms: int = 4000) -> Path: + """Navigate to URL, wait for render, take screenshot.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUTPUT_DIR / f"{name}.png" -@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") -class TestVisualRegression: - """Headless Three.js rendering tests.""" + page = browser.new_page(viewport={"width": 800, "height": 600}) + page.goto(url) + page.wait_for_timeout(wait_ms) + page.screenshot(path=str(out)) + page.close() + + return out - @pytest.fixture(autouse=True) - def setup(self): - """Ensure output dir exists.""" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - def test_steel_cube_renders(self): - """Render a steel cube with PBR material and capture screenshot.""" - try: - from build123d import Box - from pymat import Material - from ocp_vscode import show - except ImportError as e: - pytest.skip(f"Missing dependency: {e}") +@pytest.mark.skipif(SKIP_HEADLESS, reason="MAT_VIS_SKIP_HEADLESS=1 — needs ocp_vscode with built JS assets") +class TestFullPipeline: + """End-to-end: Material → build123d shape → ocp_vscode → screenshot.""" + + def test_steel_cube(self, standalone_server, browser): + """Render a steel cube with PBR scalars (no textures).""" + from build123d import Box + from pymat import Material + from ocp_vscode import show, set_port + + port = int(standalone_server.split(":")[-1]) + set_port(port) - # Create shape with material shape = Box(10, 10, 10) - shape.material = Material(name="Test Steel") - shape.material.vis.metallic = 1.0 - shape.material.vis.roughness = 0.3 - shape.material.vis.base_color = (0.8, 0.8, 0.8, 1.0) - - # This is a proof-of-concept — the actual headless rendering - # requires the standalone server + playwright wiring. - # For now, verify the adapter produces valid output. - from pymat.vis.adapters import to_threejs + m = Material(name="Test Steel") + m.vis.metallic = 1.0 + m.vis.roughness = 0.3 + m.vis.base_color = (0.8, 0.8, 0.8, 1.0) + shape.material = m - d = to_threejs(shape.material) - assert d["metalness"] == 1.0 - assert d["roughness"] == 0.3 + # Open browser FIRST so websocket connects, THEN show shape + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUTPUT_DIR / "steel_cube.png" - # Save the Three.js material dict for manual inspection - output = OUTPUT_DIR / "steel_cube_material.json" - output.write_text(json.dumps(d, indent=2, default=str)) - assert output.exists() + page = browser.new_page(viewport={"width": 800, "height": 600}) + page.goto(standalone_server) + page.wait_for_timeout(2000) # wait for websocket connect + + show(shape) + page.wait_for_timeout(5000) # wait for tessellation + render + + page.screenshot(path=str(out)) + page.close() + + assert out.exists() + assert out.stat().st_size > 1000, "Screenshot too small — likely blank" + print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") - def test_wood_with_textures_renders(self): - """Render a wood material with mat-vis textures.""" - try: - from pymat import Material, vis - except ImportError as e: - pytest.skip(f"Missing dependency: {e}") + def test_wood_with_textures(self, standalone_server, browser): + """Render a shape with mat-vis textures (color, normal maps).""" + from build123d import Box + from pymat import Material, vis + from ocp_vscode import show, set_port + + port = int(standalone_server.split(":")[-1]) + set_port(port) results = vis.search(category="wood", limit=1) if not results: pytest.skip("No wood materials in mat-vis") + shape = Box(20, 20, 5) m = Material(name="Test Wood") m.vis.roughness = 0.6 m.vis.metallic = 0.0 m.vis.base_color = (0.6, 0.4, 0.2, 1.0) m.vis.source_id = f"{results[0]['source']}/{results[0]['id']}" + shape.material = m + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUTPUT_DIR / "wood_plank.png" + + page = browser.new_page(viewport={"width": 800, "height": 600}) + page.goto(standalone_server) + page.wait_for_timeout(2000) + + show(shape) + page.wait_for_timeout(8000) # longer for texture fetch + render + + page.screenshot(path=str(out)) + page.close() + + assert out.exists() + assert out.stat().st_size > 1000 + print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") + + def test_glass_sphere_transmission(self, standalone_server, browser): + """Render a transparent glass sphere.""" + from build123d import Sphere + from pymat import Material + from ocp_vscode import show, set_port + port = int(standalone_server.split(":")[-1]) + set_port(port) + + shape = Sphere(10) + m = Material(name="Test Glass") + m.vis.roughness = 0.0 + m.vis.metallic = 0.0 + m.vis.base_color = (0.9, 0.95, 1.0, 0.3) + m.vis.ior = 1.52 + m.vis.transmission = 0.9 + shape.material = m + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUTPUT_DIR / "glass_sphere.png" + + page = browser.new_page(viewport={"width": 800, "height": 600}) + page.goto(standalone_server) + page.wait_for_timeout(2000) + + show(shape) + page.wait_for_timeout(5000) + + page.screenshot(path=str(out)) + page.close() + + assert out.exists() + assert out.stat().st_size > 1000 + print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") + + +@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") +class TestAdapterOutput: + """Verify adapter dict output without rendering (lighter, faster).""" + + def test_to_threejs_steel(self): + """to_threejs produces valid MeshPhysicalMaterial dict.""" + from pymat import Material from pymat.vis.adapters import to_threejs + m = Material(name="Steel") + m.vis.metallic = 1.0 + m.vis.roughness = 0.3 + m.vis.base_color = (0.8, 0.8, 0.8, 1.0) + d = to_threejs(m) + assert d["metalness"] == 1.0 + assert d["roughness"] == 0.3 - # Should have texture maps from mat-vis - has_textures = any(k in d for k in ("map", "normalMap", "roughnessMap")) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + (OUTPUT_DIR / "steel_threejs.json").write_text(json.dumps(d, indent=2, default=str)) - output = OUTPUT_DIR / "wood_material.json" - output.write_text(json.dumps( - {k: v[:50] + "..." if isinstance(v, str) and len(v) > 50 else v - for k, v in d.items()}, - indent=2, default=str, - )) + def test_to_threejs_with_textures(self): + """to_threejs includes base64 data URIs when textures available.""" + from pymat import Material, vis + from pymat.vis.adapters import to_threejs - assert output.exists() - # Texture presence depends on mat-vis data availability - if has_textures: - assert d["map"].startswith("data:image/png;base64,") + results = vis.search(category="metal", limit=1) + if not results: + pytest.skip("No metals in mat-vis") + m = Material(name="Textured Metal") + m.vis.metallic = 1.0 + m.vis.roughness = 0.3 + m.vis.source_id = f"{results[0]['source']}/{results[0]['id']}" -@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") -class TestHeadlessScreenshot: - """Full headless rendering via ocp_vscode standalone + Playwright. - - These tests require all dependencies (build123d, ocp_vscode, playwright) - and a working WebGL environment (headless Chrome provides this). - """ - - def test_screenshot_pipeline(self): - """Proof-of-concept: standalone server → Playwright → screenshot.""" - try: - from build123d import Box - from ocp_vscode import show, set_port - from playwright.sync_api import sync_playwright - except ImportError as e: - pytest.skip(f"Missing dependency: {e}") - - # This is the target architecture: - # 1. Start standalone server - # 2. show(shape) sends material data to server - # 3. Playwright captures screenshot - # 4. Compare to baseline - - # For now, just verify the imports and pipeline setup work - assert True # placeholder for full headless wiring + d = to_threejs(m) + + has_map = any(k in d for k in ("map", "normalMap", "roughnessMap", "metalnessMap")) + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + summary = {k: (v[:60] + "..." if isinstance(v, str) and len(v) > 60 else v) for k, v in d.items()} + (OUTPUT_DIR / "metal_textured_threejs.json").write_text(json.dumps(summary, indent=2, default=str)) + + if has_map: + assert d[next(k for k in ("map", "normalMap") if k in d)].startswith("data:image/png;base64,") diff --git a/tests/visual_output/metal_textured_threejs.json b/tests/visual_output/metal_textured_threejs.json new file mode 100644 index 0000000..ed9195b --- /dev/null +++ b/tests/visual_output/metal_textured_threejs.json @@ -0,0 +1,12 @@ +{ + "type": "MeshPhysicalMaterial", + "metalness": 1.0, + "roughness": 0.3, + "ior": 1.5, + "transmission": 0.0, + "map": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQAEAIAAA...", + "normalMap": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAA...", + "roughnessMap": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAA...", + "metalnessMap": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAA...", + "aoMap": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQAEAAAAA..." +} \ No newline at end of file diff --git a/tests/visual_output/steel_threejs.json b/tests/visual_output/steel_threejs.json new file mode 100644 index 0000000..ae8eebe --- /dev/null +++ b/tests/visual_output/steel_threejs.json @@ -0,0 +1,7 @@ +{ + "type": "MeshPhysicalMaterial", + "metalness": 1.0, + "roughness": 0.3, + "ior": 1.5, + "transmission": 0.0 +} \ No newline at end of file From 8b82ce26b6c8a55043dc8393765f3b5c6ba1593b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sat, 18 Apr 2026 01:49:32 +0200 Subject: [PATCH 31/32] =?UTF-8?q?feat:=20headless=20PBR=20rendering=20test?= =?UTF-8?q?s=20=E2=80=94=20full=20pipeline=20proven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 headless render tests via Playwright + Three.js + SwiftShader: - Steel cube (metallic=1.0, roughness=0.3) - Red sphere (dielectric, roughness=0.5) - Gold cylinder (metallic=1.0, roughness=0.2) - Glass sphere (transmission=0.9, ior=1.52) - Multi-material assembly (wood base + chrome pin) Full pipeline validated: pymat.Material → .vis scalars → build123d export_gltf → Three.js GLTFLoader (headless Chrome + SwiftShader) → WebGL MeshPhysicalMaterial render → canvas.toDataURL → PNG Key fix: build123d exports in meters, Three.js viewer scales ×1000 for visibility. Tests skip by default (MAT_VIS_SKIP_VISUAL=1). Run with: MAT_VIS_SKIP_VISUAL=0 pytest tests/test_visual_regression.py -v Also includes 2 adapter-output tests (no rendering needed). --- .gitignore | 1 + tests/test_visual_regression.py | 240 ++++++++++++++++++-------------- tests/visual_render.html | 96 +++++++++++++ 3 files changed, 229 insertions(+), 108 deletions(-) create mode 100644 tests/visual_render.html diff --git a/.gitignore b/.gitignore index 880c208..d523312 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,4 @@ justfile.local # Cursor local config .cursor/ +tests/visual_output/ diff --git a/tests/test_visual_regression.py b/tests/test_visual_regression.py index f94d722..be3b82b 100644 --- a/tests/test_visual_regression.py +++ b/tests/test_visual_regression.py @@ -1,19 +1,22 @@ -"""Visual regression tests — headless Three.js rendering via ocp_vscode + Playwright. +"""Visual regression tests — headless Three.js rendering via Playwright. Proves the full pipeline: - pymat.Material → .vis.textures (mat-vis) → to_threejs adapter - → build123d shape → ocp_vscode standalone → three-cad-viewer → screenshot + pymat.Material → .vis → to_threejs → build123d export_gltf → Three.js render → screenshot + +Uses a self-contained HTML viewer (tests/visual_render.html) that loads +Three.js from CDN + our exported glTF. No ocp_vscode needed. Requirements: - pip install playwright ocp_vscode build123d + pip install playwright build123d python -m playwright install chromium Skip with: MAT_VIS_SKIP_VISUAL=1 (default) -Run: MAT_VIS_SKIP_VISUAL=0 pytest tests/test_visual_regression.py -v +Run: MAT_VIS_SKIP_VISUAL=0 pytest tests/test_visual_regression.py -v -s """ from __future__ import annotations +import http.server import json import os import threading @@ -23,26 +26,32 @@ import pytest SKIP_VISUAL = os.environ.get("MAT_VIS_SKIP_VISUAL", "1") == "1" -SKIP_HEADLESS = os.environ.get("MAT_VIS_SKIP_HEADLESS", "1") == "1" OUTPUT_DIR = Path(__file__).parent / "visual_output" +RENDER_HTML = Path(__file__).parent / "visual_render.html" @pytest.fixture(scope="module") -def standalone_server(): - """Start ocp_vscode standalone viewer in a background thread.""" - try: - from ocp_vscode.standalone import Viewer - except ImportError: - pytest.skip("ocp_vscode not installed") +def file_server(): + """Serve test files (HTML + glTF) on localhost.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - port = 3998 # avoid conflict with default 3939 - viewer = Viewer({"port": port, "debug": False}) + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(OUTPUT_DIR), **kwargs) - thread = threading.Thread(target=viewer.start, daemon=True) - thread.start() - time.sleep(2) + def log_message(self, *args): + pass # silence + # Copy render HTML to output dir + import shutil + shutil.copy(RENDER_HTML, OUTPUT_DIR / "index.html") + + port = 8765 + server = http.server.HTTPServer(("127.0.0.1", port), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() yield f"http://127.0.0.1:{port}" + server.shutdown() @pytest.fixture(scope="module") @@ -54,144 +63,161 @@ def browser(): pytest.skip("playwright not installed") pw = sync_playwright().start() - b = pw.chromium.launch(headless=True) + b = pw.chromium.launch(headless=True, args=["--use-gl=swiftshader"]) yield b b.close() pw.stop() -def _screenshot(browser, url: str, name: str, wait_ms: int = 4000) -> Path: - """Navigate to URL, wait for render, take screenshot.""" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +def _render_and_screenshot(browser, server_url: str, glb_path: Path, name: str) -> Path: + """Load a glTF in the Three.js viewer, wait for render, extract canvas.""" + import base64 + out = OUTPUT_DIR / f"{name}.png" page = browser.new_page(viewport={"width": 800, "height": 600}) - page.goto(url) - page.wait_for_timeout(wait_ms) - page.screenshot(path=str(out)) - page.close() + page.goto(f"{server_url}/index.html?gltf={glb_path.name}") + # Wait for Three.js to signal render complete + page.wait_for_function("window.__renderComplete === true", timeout=15000) + page.wait_for_timeout(500) + + # Extract canvas content directly (more reliable than page screenshot) + data_url = page.evaluate('document.querySelector("canvas").toDataURL("image/png")') + png_bytes = base64.b64decode(data_url.split(",")[1]) + out.write_bytes(png_bytes) + + page.close() return out -@pytest.mark.skipif(SKIP_HEADLESS, reason="MAT_VIS_SKIP_HEADLESS=1 — needs ocp_vscode with built JS assets") -class TestFullPipeline: - """End-to-end: Material → build123d shape → ocp_vscode → screenshot.""" +@pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") +class TestHeadlessRender: + """Full pipeline: Material → glTF → Three.js headless → screenshot.""" - def test_steel_cube(self, standalone_server, browser): - """Render a steel cube with PBR scalars (no textures).""" - from build123d import Box + def test_steel_cube(self, file_server, browser): + """Metallic steel cube with PBR scalars.""" + from build123d import Box, export_gltf from pymat import Material - from ocp_vscode import show, set_port - - port = int(standalone_server.split(":")[-1]) - set_port(port) shape = Box(10, 10, 10) - m = Material(name="Test Steel") + m = Material(name="Steel") m.vis.metallic = 1.0 m.vis.roughness = 0.3 m.vis.base_color = (0.8, 0.8, 0.8, 1.0) shape.material = m - # Open browser FIRST so websocket connects, THEN show shape - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - out = OUTPUT_DIR / "steel_cube.png" - - page = browser.new_page(viewport={"width": 800, "height": 600}) - page.goto(standalone_server) - page.wait_for_timeout(2000) # wait for websocket connect - - show(shape) - page.wait_for_timeout(5000) # wait for tessellation + render - - page.screenshot(path=str(out)) - page.close() + glb = OUTPUT_DIR / "steel_cube.glb" + export_gltf(shape, str(glb)) + assert glb.exists() - assert out.exists() - assert out.stat().st_size > 1000, "Screenshot too small — likely blank" - print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") + screenshot = _render_and_screenshot(browser, file_server, glb, "steel_cube") + assert screenshot.exists() + size = screenshot.stat().st_size + assert size > 5000, f"Screenshot too small ({size} bytes) — likely blank" + print(f"steel_cube: {size} bytes") - def test_wood_with_textures(self, standalone_server, browser): - """Render a shape with mat-vis textures (color, normal maps).""" - from build123d import Box - from pymat import Material, vis - from ocp_vscode import show, set_port - - port = int(standalone_server.split(":")[-1]) - set_port(port) - - results = vis.search(category="wood", limit=1) - if not results: - pytest.skip("No wood materials in mat-vis") + def test_red_sphere(self, file_server, browser): + """Red dielectric sphere.""" + from build123d import Sphere, export_gltf + from pymat import Material - shape = Box(20, 20, 5) - m = Material(name="Test Wood") - m.vis.roughness = 0.6 + shape = Sphere(8) + m = Material(name="Red Plastic") m.vis.metallic = 0.0 - m.vis.base_color = (0.6, 0.4, 0.2, 1.0) - m.vis.source_id = f"{results[0]['source']}/{results[0]['id']}" + m.vis.roughness = 0.5 + m.vis.base_color = (0.8, 0.1, 0.1, 1.0) shape.material = m - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - out = OUTPUT_DIR / "wood_plank.png" + glb = OUTPUT_DIR / "red_sphere.glb" + export_gltf(shape, str(glb)) - page = browser.new_page(viewport={"width": 800, "height": 600}) - page.goto(standalone_server) - page.wait_for_timeout(2000) + screenshot = _render_and_screenshot(browser, file_server, glb, "red_sphere") + assert screenshot.exists() + size = screenshot.stat().st_size + assert size > 5000, f"Screenshot too small ({size} bytes)" + print(f"red_sphere: {size} bytes") - show(shape) - page.wait_for_timeout(8000) # longer for texture fetch + render + def test_gold_cylinder(self, file_server, browser): + """Gold metallic cylinder.""" + from build123d import Cylinder, export_gltf + from pymat import Material + + shape = Cylinder(5, 15) + m = Material(name="Gold") + m.vis.metallic = 1.0 + m.vis.roughness = 0.2 + m.vis.base_color = (1.0, 0.84, 0.0, 1.0) + shape.material = m - page.screenshot(path=str(out)) - page.close() + glb = OUTPUT_DIR / "gold_cylinder.glb" + export_gltf(shape, str(glb)) - assert out.exists() - assert out.stat().st_size > 1000 - print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") + screenshot = _render_and_screenshot(browser, file_server, glb, "gold_cylinder") + assert screenshot.exists() + size = screenshot.stat().st_size + assert size > 5000 + print(f"gold_cylinder: {size} bytes") - def test_glass_sphere_transmission(self, standalone_server, browser): - """Render a transparent glass sphere.""" - from build123d import Sphere + def test_glass_transmission(self, file_server, browser): + """Transparent glass sphere.""" + from build123d import Sphere, export_gltf from pymat import Material - from ocp_vscode import show, set_port - - port = int(standalone_server.split(":")[-1]) - set_port(port) shape = Sphere(10) - m = Material(name="Test Glass") - m.vis.roughness = 0.0 + m = Material(name="Glass") m.vis.metallic = 0.0 - m.vis.base_color = (0.9, 0.95, 1.0, 0.3) + m.vis.roughness = 0.0 + m.vis.base_color = (0.95, 0.95, 1.0, 0.3) m.vis.ior = 1.52 m.vis.transmission = 0.9 shape.material = m - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - out = OUTPUT_DIR / "glass_sphere.png" + glb = OUTPUT_DIR / "glass_sphere.glb" + export_gltf(shape, str(glb)) - page = browser.new_page(viewport={"width": 800, "height": 600}) - page.goto(standalone_server) - page.wait_for_timeout(2000) + screenshot = _render_and_screenshot(browser, file_server, glb, "glass_sphere") + assert screenshot.exists() + size = screenshot.stat().st_size + assert size > 3000 + print(f"glass_sphere: {size} bytes") - show(shape) - page.wait_for_timeout(5000) + def test_multi_material_assembly(self, file_server, browser): + """Assembly with different materials per part.""" + from build123d import Box, Cylinder, Pos, Compound, export_gltf + from pymat import Material + + base = Box(20, 20, 3) + m_base = Material(name="Wood Base") + m_base.vis.metallic = 0.0 + m_base.vis.roughness = 0.7 + m_base.vis.base_color = (0.6, 0.4, 0.2, 1.0) + base.material = m_base + + pin = Pos(0, 0, 10) * Cylinder(3, 14) + m_pin = Material(name="Chrome Pin") + m_pin.vis.metallic = 1.0 + m_pin.vis.roughness = 0.1 + m_pin.vis.base_color = (0.9, 0.9, 0.9, 1.0) + pin.material = m_pin - page.screenshot(path=str(out)) - page.close() + assembly = Compound(children=[base, pin]) - assert out.exists() - assert out.stat().st_size > 1000 - print(f"Screenshot saved: {out} ({out.stat().st_size} bytes)") + glb = OUTPUT_DIR / "assembly.glb" + export_gltf(assembly, str(glb)) + + screenshot = _render_and_screenshot(browser, file_server, glb, "assembly") + assert screenshot.exists() + size = screenshot.stat().st_size + assert size > 5000 + print(f"assembly: {size} bytes") @pytest.mark.skipif(SKIP_VISUAL, reason="MAT_VIS_SKIP_VISUAL=1 (default)") class TestAdapterOutput: - """Verify adapter dict output without rendering (lighter, faster).""" + """Verify adapter dict output (no rendering needed).""" - def test_to_threejs_steel(self): - """to_threejs produces valid MeshPhysicalMaterial dict.""" + def test_to_threejs_scalars(self): from pymat import Material from pymat.vis.adapters import to_threejs @@ -208,7 +234,6 @@ def test_to_threejs_steel(self): (OUTPUT_DIR / "steel_threejs.json").write_text(json.dumps(d, indent=2, default=str)) def test_to_threejs_with_textures(self): - """to_threejs includes base64 data URIs when textures available.""" from pymat import Material, vis from pymat.vis.adapters import to_threejs @@ -222,7 +247,6 @@ def test_to_threejs_with_textures(self): m.vis.source_id = f"{results[0]['source']}/{results[0]['id']}" d = to_threejs(m) - has_map = any(k in d for k in ("map", "normalMap", "roughnessMap", "metalnessMap")) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) diff --git a/tests/visual_render.html b/tests/visual_render.html new file mode 100644 index 0000000..8a004c8 --- /dev/null +++ b/tests/visual_render.html @@ -0,0 +1,96 @@ + + + + + + + + + + From 4e4eb2f41d58c0fe15e5a7c4c54b5e11c58a1177 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sat, 18 Apr 2026 10:06:22 +0200 Subject: [PATCH 32/32] feat: tag-based vis matching + curated mappings + 4 thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pymat/vis/__init__.py: Added tags= parameter to search() — require all given tags present in entry. Uses ambientcg/polyhaven semantic tags ("brushed", "silver", "oak", "concrete") for much better matches than category alone. metals.toml: Curated [vis] mappings for 5 metals based on tag matching: stainless: brushed=Metal012, polished=Metal049A, dirty=Metal049B aluminum: smooth=Metal049A, machined=Metal055A copper: polished=Metal043A, oxidized=Metal043B, aged=Metal026 titanium: smooth=Metal049A brass: polished=Metal008, oxidized=Metal035 scripts/enrich_vis.py: Rewrote to use tag-based matching. Produces more accurate proposals. scripts/generate_catalog.py: Fetches 128px thumbnails from mat-vis when vis.source_id is set. 4 thumbnails committed for aluminum, brass, copper, titanium. docs/catalog/: Regenerated with the new mappings. --- docs/catalog/ceramics/README.md | 24 +-- docs/catalog/ceramics/glass-BK7.md | 2 +- docs/catalog/ceramics/glass-fused_silica.md | 4 +- docs/catalog/electronics/README.md | 28 ++-- docs/catalog/gases/README.md | 34 ++-- docs/catalog/liquids/README.md | 16 +- docs/catalog/metals/README.md | 44 ++--- docs/catalog/metals/aluminum.md | 10 ++ docs/catalog/metals/brass.md | 10 ++ docs/catalog/metals/copper.md | 10 ++ docs/catalog/metals/stainless.md | 4 +- docs/catalog/metals/thumbs/aluminum.png | Bin 0 -> 12090 bytes docs/catalog/metals/thumbs/brass.png | Bin 0 -> 28131 bytes docs/catalog/metals/thumbs/copper.png | Bin 0 -> 18594 bytes docs/catalog/metals/thumbs/titanium.png | Bin 0 -> 12090 bytes docs/catalog/metals/titanium.md | 10 ++ docs/catalog/plastics/README.md | 54 ++++--- docs/catalog/scintillators/README.md | 30 ++-- scripts/enrich_vis.py | 168 ++++++++++++-------- scripts/generate_catalog.py | 116 +++++++++++--- src/pymat/data/metals.toml | 25 ++- src/pymat/vis/__init__.py | 15 +- 22 files changed, 407 insertions(+), 197 deletions(-) create mode 100644 docs/catalog/metals/thumbs/aluminum.png create mode 100644 docs/catalog/metals/thumbs/brass.png create mode 100644 docs/catalog/metals/thumbs/copper.png create mode 100644 docs/catalog/metals/thumbs/titanium.png diff --git a/docs/catalog/ceramics/README.md b/docs/catalog/ceramics/README.md index f35e058..1993ddb 100644 --- a/docs/catalog/ceramics/README.md +++ b/docs/catalog/ceramics/README.md @@ -1,13 +1,15 @@ # Ceramics -| Material | Density | Roughness | Metallic | -|---|---|---|---| -| [Alumina](alumina.md) | 3.95 g/cm³ | 0.5 | 0.0 | -| [99.5% Alumina](alumina-al995.md) | 3.89 g/cm³ | 0.5 | 0.0 | -| [99.9% Alumina](alumina-al999.md) | 3.96 g/cm³ | 0.5 | 0.0 | -| [Macor](macor.md) | 2.52 g/cm³ | 0.4 | 0.0 | -| [Zirconia](zirconia.md) | 6.0 g/cm³ | 0.4 | 0.0 | -| [Glass](glass.md) | 2.5 g/cm³ | 0.1 | 0.0 | -| [Borosilicate Glass](glass-borosilicate.md) | 2.5 g/cm³ | 0.1 | 0.0 | -| [Fused Silica](glass-fused_silica.md) | 2.2 g/cm³ | 0.1 | 0.0 | -| [BK7 Optical Glass](glass-BK7.md) | 2.5 g/cm³ | 0.1 | 0.0 | +9 materials. Click a name for full properties. + +| Material | Preview | Density | E | T_melt | +|---|---|---|---|---| +| [Alumina](alumina.md) | — | 3.95 g/cm³ | 345 GPa | 2072 °C | +| [99.5% Alumina](alumina-al995.md) | — | 3.89 g/cm³ | 345 GPa | 2072 °C | +| [99.9% Alumina](alumina-al999.md) | — | 3.96 g/cm³ | 345 GPa | 2072 °C | +| [Macor](macor.md) | — | 2.52 g/cm³ | 66 GPa | 1000 °C | +| [Zirconia](zirconia.md) | — | 6.0 g/cm³ | 200 GPa | 2715 °C | +| [Glass](glass.md) | — | 2.5 g/cm³ | 70 GPa | 1600 °C | +| [Borosilicate Glass](glass-borosilicate.md) | — | 2.5 g/cm³ | 70 GPa | 1700 °C | +| [Fused Silica](glass-fused_silica.md) | — | 2.2 g/cm³ | 70 GPa | 1600 °C | +| [BK7 Optical Glass](glass-BK7.md) | — | 2.5 g/cm³ | 70 GPa | 1600 °C | diff --git a/docs/catalog/ceramics/glass-BK7.md b/docs/catalog/ceramics/glass-BK7.md index bf2d844..8baf0f2 100644 --- a/docs/catalog/ceramics/glass-BK7.md +++ b/docs/catalog/ceramics/glass-BK7.md @@ -29,5 +29,5 @@ | Base Color | `(0.9, 0.93, 0.95, 0.85)` | | Metallic | 0.0 | | Roughness | 0.1 | -| IOR | 1.52 | +| IOR | 1.517 | | Transmission | 0.95 | diff --git a/docs/catalog/ceramics/glass-fused_silica.md b/docs/catalog/ceramics/glass-fused_silica.md index 9769a1b..3ce1ef5 100644 --- a/docs/catalog/ceramics/glass-fused_silica.md +++ b/docs/catalog/ceramics/glass-fused_silica.md @@ -30,5 +30,5 @@ | Base Color | `(0.9, 0.93, 0.95, 0.85)` | | Metallic | 0.0 | | Roughness | 0.1 | -| IOR | 1.52 | -| Transmission | 0.9 | +| IOR | 1.46 | +| Transmission | 0.99 | diff --git a/docs/catalog/electronics/README.md b/docs/catalog/electronics/README.md index a1fc1ff..cdcc488 100644 --- a/docs/catalog/electronics/README.md +++ b/docs/catalog/electronics/README.md @@ -1,15 +1,17 @@ # Electronics -| Material | Density | Roughness | Metallic | -|---|---|---|---| -| [FR4](fr4.md) | 1.86 g/cm³ | 0.7 | 0.0 | -| [Rogers RF Laminate](rogers.md) | 1.9 g/cm³ | 0.6 | 0.0 | -| [Rogers 4350B](rogers-r4350b.md) | 1.86 g/cm³ | 0.6 | 0.0 | -| [Kapton (Polyimide)](kapton.md) | 1.42 g/cm³ | 0.5 | 0.0 | -| [Copper (PCB)](copper_pcb.md) | 8.96 g/cm³ | 0.35 | 1.0 | -| [1 oz Copper (35 µm)](copper_pcb-oz1.md) | 8.96 g/cm³ | 0.35 | 1.0 | -| [2 oz Copper (70 µm)](copper_pcb-oz2.md) | 8.96 g/cm³ | 0.35 | 1.0 | -| [Gold Plated Copper (ENIG)](copper_pcb-gold_plated.md) | 8.96 g/cm³ | 0.2 | 1.0 | -| [Solder](solder.md) | 8.4 g/cm³ | 0.4 | 1.0 | -| [Sn63Pb37 (60% Tin / 40% Lead)](solder-Sn63Pb37.md) | 8.4 g/cm³ | 0.4 | 1.0 | -| [SAC305 (96.5% Tin / 3% Silver / 0.5% Copper)](solder-SAC305.md) | 7.3 g/cm³ | 0.4 | 1.0 | +11 materials. Click a name for full properties. + +| Material | Preview | Density | +|---|---|---| +| [FR4](fr4.md) | — | 1.86 g/cm³ | +| [Rogers RF Laminate](rogers.md) | — | 1.9 g/cm³ | +| [Rogers 4350B](rogers-r4350b.md) | — | 1.86 g/cm³ | +| [Kapton (Polyimide)](kapton.md) | — | 1.42 g/cm³ | +| [Copper (PCB)](copper_pcb.md) | — | 8.96 g/cm³ | +| [1 oz Copper (35 µm)](copper_pcb-oz1.md) | — | 8.96 g/cm³ | +| [2 oz Copper (70 µm)](copper_pcb-oz2.md) | — | 8.96 g/cm³ | +| [Gold Plated Copper (ENIG)](copper_pcb-gold_plated.md) | — | 8.96 g/cm³ | +| [Solder](solder.md) | — | 8.4 g/cm³ | +| [Sn63Pb37 (60% Tin / 40% Lead)](solder-Sn63Pb37.md) | — | 8.4 g/cm³ | +| [SAC305 (96.5% Tin / 3% Silver / 0.5% Copper)](solder-SAC305.md) | — | 7.3 g/cm³ | diff --git a/docs/catalog/gases/README.md b/docs/catalog/gases/README.md index cf9c469..6f66234 100644 --- a/docs/catalog/gases/README.md +++ b/docs/catalog/gases/README.md @@ -1,18 +1,20 @@ # Gases -| Material | Density | Roughness | Metallic | -|---|---|---|---| -| [Air](air.md) | 0.001204 g/cm³ | 0.0 | 0.0 | -| [Nitrogen](nitrogen.md) | 0.001165 g/cm³ | 0.0 | 0.0 | -| [Liquid Nitrogen](nitrogen-liquid.md) | 0.808 g/cm³ | 0.0 | 0.0 | -| [Oxygen](oxygen.md) | 0.001331 g/cm³ | 0.0 | 0.0 | -| [Argon](argon.md) | 0.001662 g/cm³ | 0.0 | 0.0 | -| [Carbon Dioxide](co2.md) | 0.001839 g/cm³ | 0.0 | 0.0 | -| [Dry Ice](co2-dry_ice.md) | 1.56 g/cm³ | 0.3 | 0.0 | -| [Helium](helium.md) | 0.000166 g/cm³ | 0.0 | 0.0 | -| [Liquid Helium](helium-liquid.md) | 0.125 g/cm³ | 0.0 | 0.0 | -| [Hydrogen](hydrogen.md) | 8.38e-05 g/cm³ | 0.0 | 0.0 | -| [Neon](neon.md) | 0.000839 g/cm³ | 0.0 | 0.0 | -| [Xenon](xenon.md) | 0.005458 g/cm³ | 0.0 | 0.0 | -| [Methane](methane.md) | 0.000668 g/cm³ | 0.0 | 0.0 | -| [Vacuum](vacuum.md) | — | 0.0 | 0.0 | +14 materials. Click a name for full properties. + +| Material | Preview | Density | +|---|---|---| +| [Air](air.md) | — | 0.001204 g/cm³ | +| [Nitrogen](nitrogen.md) | — | 0.001165 g/cm³ | +| [Liquid Nitrogen](nitrogen-liquid.md) | — | 0.808 g/cm³ | +| [Oxygen](oxygen.md) | — | 0.001331 g/cm³ | +| [Argon](argon.md) | — | 0.001662 g/cm³ | +| [Carbon Dioxide](co2.md) | — | 0.001839 g/cm³ | +| [Dry Ice](co2-dry_ice.md) | — | 1.56 g/cm³ | +| [Helium](helium.md) | — | 0.000166 g/cm³ | +| [Liquid Helium](helium-liquid.md) | — | 0.125 g/cm³ | +| [Hydrogen](hydrogen.md) | — | 8.38e-05 g/cm³ | +| [Neon](neon.md) | — | 0.000839 g/cm³ | +| [Xenon](xenon.md) | — | 0.005458 g/cm³ | +| [Methane](methane.md) | — | 0.000668 g/cm³ | +| [Vacuum](vacuum.md) | — | — | diff --git a/docs/catalog/liquids/README.md b/docs/catalog/liquids/README.md index db14581..78ae3de 100644 --- a/docs/catalog/liquids/README.md +++ b/docs/catalog/liquids/README.md @@ -1,10 +1,12 @@ # Liquids -| Material | Density | Roughness | Metallic | +6 materials. Click a name for full properties. + +| Material | Preview | Density | n (IOR) | |---|---|---|---| -| [Water](water.md) | 0.998 g/cm³ | 0.0 | 0.0 | -| [Ice](water-ice.md) | 0.917 g/cm³ | 0.1 | 0.0 | -| [Heavy Water (D2O)](heavy_water.md) | 1.107 g/cm³ | 0.0 | 0.0 | -| [Mineral Oil](mineral_oil.md) | 0.85 g/cm³ | 0.0 | 0.0 | -| [Glycerol](glycerol.md) | 1.261 g/cm³ | 0.0 | 0.0 | -| [Silicone Oil](silicone_oil.md) | 0.97 g/cm³ | 0.0 | 0.0 | +| [Water](water.md) | — | 0.998 g/cm³ | 1.333 | +| [Ice](water-ice.md) | — | 0.917 g/cm³ | 1.333 | +| [Heavy Water (D2O)](heavy_water.md) | — | 1.107 g/cm³ | 1.328 | +| [Mineral Oil](mineral_oil.md) | — | 0.85 g/cm³ | 1.47 | +| [Glycerol](glycerol.md) | — | 1.261 g/cm³ | 1.473 | +| [Silicone Oil](silicone_oil.md) | — | 0.97 g/cm³ | 1.4 | diff --git a/docs/catalog/metals/README.md b/docs/catalog/metals/README.md index 5c56673..ef2ed1d 100644 --- a/docs/catalog/metals/README.md +++ b/docs/catalog/metals/README.md @@ -1,23 +1,25 @@ # Metals -| Material | Density | Roughness | Metallic | -|---|---|---|---| -| [Stainless Steel](stainless.md) | 8.0 g/cm³ | 0.3 | 1.0 | -| [Stainless Steel 304](stainless-s304.md) | 8.0 g/cm³ | 0.3 | 1.0 | -| [Stainless Steel 316L](stainless-s316L.md) | 8.0 g/cm³ | 0.3 | 1.0 | -| [Stainless Steel 303](stainless-s303.md) | 8.0 g/cm³ | 0.3 | 1.0 | -| [Stainless Steel 17-4 PH](stainless-s17_4PH.md) | 7.8 g/cm³ | 0.3 | 1.0 | -| [Aluminum](aluminum.md) | 2.7 g/cm³ | 0.4 | 1.0 | -| [Aluminum 6061-T6](aluminum-a6061.md) | 2.7 g/cm³ | 0.4 | 1.0 | -| [Aluminum 6063](aluminum-a6063.md) | 2.69 g/cm³ | 0.4 | 1.0 | -| [Aluminum 7075](aluminum-a7075.md) | 2.81 g/cm³ | 0.4 | 1.0 | -| [Aluminum 2024](aluminum-a2024.md) | 2.78 g/cm³ | 0.4 | 1.0 | -| [Copper](copper.md) | 8.96 g/cm³ | 0.3 | 1.0 | -| [OFHC Copper (Oxygen-Free High Conductivity)](copper-OFHC.md) | 8.94 g/cm³ | 0.3 | 1.0 | -| [Tungsten](tungsten.md) | 19.3 g/cm³ | 0.4 | 1.0 | -| [Tungsten 99.95%](tungsten-pure.md) | 19.3 g/cm³ | 0.4 | 1.0 | -| [Tungsten Heavy Alloy (90% W)](tungsten-W90.md) | 17.0 g/cm³ | 0.4 | 1.0 | -| [Lead](lead.md) | 11.34 g/cm³ | 0.5 | 1.0 | -| [Titanium](titanium.md) | 4.51 g/cm³ | 0.3 | 1.0 | -| [Ti-6Al-4V (Grade 5)](titanium-grade5.md) | 4.43 g/cm³ | 0.3 | 1.0 | -| [Brass (Cu-Zn)](brass.md) | 8.5 g/cm³ | 0.25 | 1.0 | +19 materials. Click a name for full properties. + +| Material | Preview | Density | Yield | Tensile | E | T_melt | +|---|---|---|---|---|---|---| +| [Stainless Steel](stainless.md) | ![](thumbs/stainless.png) | 8.0 g/cm³ | — | — | 193 GPa | 1450 °C | +| [Stainless Steel 304](stainless-s304.md) | — | 8.0 g/cm³ | 170 MPa | 515 MPa | 193 GPa | 1450 °C | +| [Stainless Steel 316L](stainless-s316L.md) | — | 8.0 g/cm³ | 170 MPa | 485 MPa | 193 GPa | 1450 °C | +| [Stainless Steel 303](stainless-s303.md) | — | 8.0 g/cm³ | 205 MPa | 515 MPa | 193 GPa | 1450 °C | +| [Stainless Steel 17-4 PH](stainless-s17_4PH.md) | — | 7.8 g/cm³ | 1170 MPa | 1310 MPa | 193 GPa | 1450 °C | +| [Aluminum](aluminum.md) | ![](thumbs/aluminum.png) | 2.7 g/cm³ | — | — | 69 GPa | 660 °C | +| [Aluminum 6061-T6](aluminum-a6061.md) | — | 2.7 g/cm³ | 276 MPa | 310 MPa | 69 GPa | 660 °C | +| [Aluminum 6063](aluminum-a6063.md) | — | 2.69 g/cm³ | 48 MPa | 130 MPa | 69 GPa | 660 °C | +| [Aluminum 7075](aluminum-a7075.md) | — | 2.81 g/cm³ | — | — | 72 GPa | 660 °C | +| [Aluminum 2024](aluminum-a2024.md) | — | 2.78 g/cm³ | — | — | 69 GPa | 660 °C | +| [Copper](copper.md) | ![](thumbs/copper.png) | 8.96 g/cm³ | 40 MPa | 200 MPa | 110 GPa | 1085 °C | +| [OFHC Copper (Oxygen-Free High Conductivity)](copper-OFHC.md) | — | 8.94 g/cm³ | 40 MPa | 200 MPa | 110 GPa | 1085 °C | +| [Tungsten](tungsten.md) | — | 19.3 g/cm³ | — | — | 411 GPa | 3422 °C | +| [Tungsten 99.95%](tungsten-pure.md) | — | 19.3 g/cm³ | — | — | 411 GPa | 3422 °C | +| [Tungsten Heavy Alloy (90% W)](tungsten-W90.md) | — | 17.0 g/cm³ | — | — | 411 GPa | 3422 °C | +| [Lead](lead.md) | — | 11.34 g/cm³ | 12 MPa | — | 16 GPa | 327 °C | +| [Titanium](titanium.md) | ![](thumbs/titanium.png) | 4.51 g/cm³ | 880 MPa | 950 MPa | 103 GPa | 1668 °C | +| [Ti-6Al-4V (Grade 5)](titanium-grade5.md) | — | 4.43 g/cm³ | 880 MPa | 950 MPa | 103 GPa | 1668 °C | +| [Brass (Cu-Zn)](brass.md) | ![](thumbs/brass.png) | 8.5 g/cm³ | 200 MPa | — | 97 GPa | 900 °C | diff --git a/docs/catalog/metals/aluminum.md b/docs/catalog/metals/aluminum.md index 040b1ce..ca9ebd0 100644 --- a/docs/catalog/metals/aluminum.md +++ b/docs/catalog/metals/aluminum.md @@ -1,5 +1,7 @@ # Aluminum +![Aluminum](thumbs/aluminum.png) + ## Identity | Field | Value | @@ -29,3 +31,11 @@ | Base Color | `(0.88, 0.88, 0.88, 1.0)` | | Metallic | 1.0 | | Roughness | 0.4 | + +## Visual (mat-vis) + +| Field | Value | +|---|---| +| Source ID | `ambientcg/Metal049A` | +| Finish | smooth | +| Available Finishes | smooth, machined | diff --git a/docs/catalog/metals/brass.md b/docs/catalog/metals/brass.md index f5cc324..5aeb23c 100644 --- a/docs/catalog/metals/brass.md +++ b/docs/catalog/metals/brass.md @@ -1,5 +1,7 @@ # Brass (Cu-Zn) +![Brass (Cu-Zn)](thumbs/brass.png) + ## Identity | Field | Value | @@ -27,6 +29,14 @@ | Metallic | 1.0 | | Roughness | 0.25 | +## Visual (mat-vis) + +| Field | Value | +|---|---| +| Source ID | `ambientcg/Metal008` | +| Finish | polished | +| Available Finishes | polished, oxidized | + ## Composition | Element | Fraction | diff --git a/docs/catalog/metals/copper.md b/docs/catalog/metals/copper.md index 53b5007..d46d543 100644 --- a/docs/catalog/metals/copper.md +++ b/docs/catalog/metals/copper.md @@ -1,5 +1,7 @@ # Copper +![Copper](thumbs/copper.png) + ## Identity | Field | Value | @@ -30,3 +32,11 @@ | Base Color | `(0.72, 0.45, 0.2, 1.0)` | | Metallic | 1.0 | | Roughness | 0.3 | + +## Visual (mat-vis) + +| Field | Value | +|---|---| +| Source ID | `ambientcg/Metal043A` | +| Finish | polished | +| Available Finishes | polished, oxidized, aged | diff --git a/docs/catalog/metals/stainless.md b/docs/catalog/metals/stainless.md index 295be84..ac78ab8 100644 --- a/docs/catalog/metals/stainless.md +++ b/docs/catalog/metals/stainless.md @@ -33,9 +33,9 @@ | Field | Value | |---|---| -| Source ID | `ambientcg/Metal032` | +| Source ID | `ambientcg/Metal012` | | Finish | brushed | -| Available Finishes | brushed, polished | +| Available Finishes | brushed, polished, dirty | ## Composition diff --git a/docs/catalog/metals/thumbs/aluminum.png b/docs/catalog/metals/thumbs/aluminum.png new file mode 100644 index 0000000000000000000000000000000000000000..e0b7e399e281d0af9dee0eb07654fa7da30626c4 GIT binary patch literal 12090 zcmV-AFU8P_P)eMLk?W>lelw9Ldn=b=Bjik+S{Z6x6|KBH&&^P?D*scCcCZ!@1ELI~x^ zlqI8^9v=w;M~1|QCVzyRpOPN^U5XDfb(KCZGvJuQZ;dI`%AU+e`c+g$6y9fB1;tFp z64gd!e!a=|*-Q{B=6c?bMjnwmm@x{(`-;KK-J!Q!zAUK#+0hZhf zS2LoD5LopqVpjw4tHLNZxC-SgmkAt>!hav$gD{amK4d}-oB}>v9f7EPO2f#k{i=}r z0lo8WH)^#~>5n^pE6k}E6nIo*TOAOYHcc=y6#?Z>sYH z5QzsfS8ep~e-w-qG$4fV#`J?o)&k$viL>&JXi zALN#B>lz?5nbG3AMW##9FAJvtNn|BV}j%DNse>%Zy!JOT6^TuS-yB zbL2-2(-tRtFK3WoMFV*Y$>_z()V+R6=Mk%%&_e-)h^wwn#}ZOPLC%ITCo!dpMT)mn zQr{fS4mUZNU25vju)TY6+MlIK4mG6=S>cRKtu+f58FbOUz6U+fE9d9sw2Y{e0j=!O zK&o5yBQE7;#NaYTUxZd22(_`e0@7yprux%x7EW8FZ0ArS#>k37oACZ ztGZle_;iDmw6^hYCRilMB_byoHN1#kbU%geXj0plohwW=npeAOF}?2ZGz1GK6tb#v zSJx}2!@`ICkUY<+Hpi84VEv)}`^l(7K{DXFFgrn%J(G2}F-ai0SXmhM*i#G@w(0mf z*2AzzF@+U%P|6~=ELyu|{sYJJDtn0KV6F*_PLZ`{Y4?|P2aS9%IjUZTE3(cyfD20| zmnTH7?;ZY9Xi#%?M@+{i_es>ZJQWgkGJ$gS%5KeB#ac3iDVWq#)LN6f)KwHA#+d1e zBrLX2>V*5FJCPe0q`$~{m*V}dMIU7P^--@?6$h7?DoTSzUov|%HYrd5>A%LJQK;LDAzM(Tv3=%MPwCMp`<0~K6=Y@FIOf!@=SKCtUE%^eh!}XWlLTYUK*cN zaJUPQEtb!)F5fJz##}tMs=vv~yjYl8>D#cL*Hic0g+>E;$5uqYTJDXV7{f9g=w7U- zmmGipMB{Q%`bR{;xplQBlPFFy^M&$#W%ufD_BsKcFLxm};Cf6QYquu(eE;ejD<#WW z;t^PbJj`-{a<4uXQiV3OqpfEJ;Y>7|{SVXv|_=jollzZ-v# znG?|@A!?gFaC%grn2epTr zedFr1(--y48C`2Vp*c$v$w8Rg(wc1R!Sot#qxZYNeg4N}aV-2>=R3mL=Ea}_%~CF= z%FuFqI+RYpD&$kOM7veYp}06+=aHtQ1H!Xpg(9~XW%o(x>l}nF3V9Waj+Gm!F|9`j z_*EwSm<~pC3Xs!h_-q=CG5Pc_WXw80X$GtCFKbs$m~?)CQ$Z}zZbo_~^?Z!7fUEiU zI&hVY>>*aKdWWF%YQOqW=1|atjO=heHG-qK4($N=88%oUQkH`>q6l|_h*b96a8cjc zYGuCXz&d50QJIh+ofV#XE0ZzFj^5Nv48%#G(%R@6`rj1kfqOgp7yuspVB2g0QCgwz zjG+*Ic$3##EN8gB1!Re`bY6*PZUY}WbpG>?PqMtNl2G_3u8kyakP(v43NJ;dQYr@5i%^o@vdv9u&GhOM*`F1FR-a)qv9MB`B!Z*- zY1P5YgJoO$^uwW783P^|>|$P>KTnL8TwB&=wDANIagEWM7ra-s(hOgzydyo#kf|I{ zz_y1=hw`A>JMUf|bfR4QUlo|=C=)^bL!kfn{x;Zb_Bnh>HR?!*GN7Z!3U|cnJ~2KJ zp((-xrp_7VxNV?0_51WHn3FFx|bFcb0{yG zc0EOFrj9l(zWj_3YJDCdqh9)>hrUM#{;vIfh>IqK&I8ItmwnInxmb_#$(zvw>}cBh z{^R46ZKt(0PEs*^qeTlnjk(Se_Br$Wa|L_jRjy&F{BZFcvj*f5(>fUE=VbX`h&KGL z;Q5}AXjUQJvWM3w4E#$bJhOIOWR}p^2njXD@nG0@hGT)tyKjlU-+)Zd5mMHUYLIfP z^&t~lgT&MsaAj&U4#sQIEG2lm$XX(w&+r*DOg8SW`2Dxe#7Q`cHOpUGPrC4cec8#- zFLd|qiu-j4tn-qf7cI8<(mPE^i_Y^f_fbbfTMv=!qu#Ie7<23jhRVx)>Le@Bui!& z-bR3S0Od=>NyytgEzzg<8?B53U6NEwq}1dS>rI+Gb*VL6*cncCKFKS?LUn^+hJy|) zjg?r41?7@GoM$bTyV3BVR_gp@%W+W>u7G0lvhRRW_S_vkua3>LmLw6WW0SyK`GS(w zY7A8_l@>T1=38m@H_wHiy4T!b54$K*F zjj%hG|CNoMTX8vL+|%569kXIQZZIS@+m>0 zm-g-DTsKUYFRu_w^*eX6TeLp(kdM0H&?aLh73*RwS@brpc{};4Y+%I5{T@aV|B;}2 z#1_NUC&QzIF?6o56d2%)v%VQ*J*K+*r@=jhrbxpEV%pk{WGWbd=QZ) zX4%JfosP3d8>a?TmoJr?Zk;QL;$orvC?X|hD7<}aW17<{8o5~XrDMsOYa9tSgTOhA zTM)lrU`vM0J0=P8BCFI#e4CE9jHmq!a(j3C9k~jM6s+kQ__1|hw}k#&6QqpUytNyH zj=s1hGz5RP-a~rb3xC{r4BG7X(S`@yPwJJ9xY}rI;FIpF$$67rG$lfTDj&Dvm*F|` z8EzfjcMorcyrdJ3a5-(sdSWaQhHsK*hjKB*mP?K%MzvHpMhT7cg3fR66jL6lBD;IOljK-r2hjJu_%wxHo)@!p%lLqK}vk&zznA3*~J z^dOrXZ&lz}X-{mCq3}miMoXXyEGr`%VCu+P4Sx42d@=)g#uf6HsZ;_8iikzH=v|72 z%y=VO{00LOMa6OncLi6pZbZIRq5kHTr|mq>o3$2{z6EUw&E0j+(Vqq~W-L%D0DWIs zqHzAx9t~a>^ckc=_+yW=ZW!7gHB1_1D4`6eMTylAsbo9l}fIJ`Cxpx~iC zbQ!Vb62UpSQ0^f|Bi?~TLP&A~@n~W=82u(A5VNmJH`157GyySa@KPjTk zS6Q}rkm`EG zl+~D5mjFE*ehbqzl9`|=PJD3|n(<;!5=NqQPJ1#Q6(YQ%(V#3e4dD}6Kybt_9{VuQ z&Gc-)41;W=tG;^U=bDCiub&-lcQ`ZDQVEeJ#j+JfvY3}1;)!B}rb^MmBOefQv|t2X z6cbncMmin*MWDZB=sZmliY^o(bY4jr8g$at!&NQzdFvY#;VrdO5l z)!Dv`#kYG&QcgmPtqi&*)%e@*O`y96P~fo9~n z7o;t@MxrNMLhg7qHA`es6iq2 zGr%pxH9IiVq^|TYA?P2c@#mdj;*^&&0#I!PX8q(4jByc{jlB{~qS=&KT?u9`1fzIw zqnN1#HX5N5yx#g#1G>Fe>q zlGREsj;8-6hL&ihDGGZzYy(9B!;Cl-v;tv%^(E7Coo*d-z7j=B$ZQ2;ZEU5CHZ-#h zT2#6TbUdmuvl2ukD@lb{wVO$THl40^0u+1fNjU-K4jsjxHnnOIoM@0Jmk{|I`*d$GDE;aSfh$)2e05XZ9 z0R809e)xlK=1lcmZ|4SC@*RfJ9K4JQH1LGJn5v9P(~r71kx(jZ0zuF)@=9ZuX?O>c z8oX2j2APOHJrt=_AV=B#u?l5VIUl(lPrfWdzZ~=eZ)E3 z*&T;EK#60*yN+>QR)pE*zBMi$TCQlR|nVx2#JbJ^(G*_e$secFS0x>>Qkr)R5fDdl4I~QKEcIUbQ!kux$i2C%{MQKjuTc4-KCT(qYK#kZo8}9?GbQzu>g8$B1V#JGrU+Lh_FJex2)%DjFqq(sR;V zpyUi>Z6q8NbQ!F9pu89=jLaN#iLeRCHjQ3%l33Pf?Dp((%pXtmh`%vd(sRr^Ku|ry zODdK<*KhnQI}p@hO$ zchsQO-$0)oQDPw(=4#r6)l1g=sXZ(;Ml1}<5Kg#ftaWX~lI2R}p{A+r&sQ?dpgC3# z7|U&8-l{GYeoSo>)I1#ed{oT;rPa<+LAly4%F-MmM?gXl#wW|2-w`-H9!^$k9LZ3Ge73R5LG0+0>vh=oO$So&Ocx>lx>)~BEv`dp+ zmo##Zn^e5Q^=px!P0p8e`ZyeeiP_JvK7AIconH(kx@6aie$12J@b)#T3?cSE#(RY_ zfXAEhk^AM(3F5+Nd1$tYU}<*BayTBRjDQ-uUQ5|nv40ZCGa&5{W@0=?AU`B}z8gF- z5j=EB5I2<*6Y|f3wpIVeTHA<>bZ0`kxx(fG7X%FbqNQ&vgzN;}p#&BAkN^0@4z4$IN*@{UG3-cO?d9mZh zP?w935uJFiQ96Hi*{HHuyNd&x>#961b> zMW@_qZhBcIz>sRAMhzwndeO$$zbg;cDcjo5|A`7FzGVAOa`aRl6wv9tourh1sYy(8 z969|u%61CVLrw)s@Qp;HWx^dZCX5!tyl%uHW~}bJfFe29@};}v?*PH8*yr8mKK?GZ zp3SfSiWydDb0-d9tQoq5Gc$DRR9%EFzeRgDothTax_B+G=+`lg zT(^1#`2Dk4zsl7`+M<3`%mPdI?_zpH=>H=&+@7QH_|W_iX;wsJd#+C&3M!tss8*d$ zUEZ~1ujZdbSa=k3;;7L}_vv!p-fjKXzmSm|ea;u?HX)mh(gVVLA(zBJF%L1b_#0h2 zN5+7d=R~W*=E73Q^le6(?fbQ%kMDEuK? zUe)C5p6Bo?Mn;tXC(Fv!`Iu`1hw32R8=xa(OM*yutJ1xtNvJcl8S z8W9K&Cu;hMb5t9*hYL+(CXQ-3c6|X_G$*cvvFzbqDtJ@yO(xIg8{B6Wbdw@_mu|5g zY+$b3kJ~*0} z_i`G9r~HQP(}4Ewc!i&@N^>cGP0SWX*biV9UbP8yy*he(N=Cc8#k)gb!f}3LDe$N0 zsJh1 zKNMll)9R131XwjF3zT*L3whk49OZc)vs}o*@C!JcE#J-Ib?TXB zOOcn;eUjs|dW^NzeY~nj2lFl8}?QvEHf0B8pT$Z%1BpfGAQx$ zZpLxjpntw+up>%oF0V8|yt7537z*|qRK{s+ob!_FKmr) z!3>~37w}pt8pTn)mRV@9O#|28H~47*^Ho}+rZPote~1#h2>Sv=G%~hHoJR^vQ(%iC zKT)Uwdc4x?Kg(L0H^s+*XdbK_}Nfv;o-`^rlT5bn#;5ALU=TfHp{P1{+Uf+~dkmjD88DnA7 z*j8<^YePsSC~PgsU`;9ew5RDVW1cf*budXU%+$_Lm2E3i8L%kmA_$UluJ+BJ93Oo0 z{Q*nfGDjklrUw&(*-pKcXDcC442Wz#a3oH6u1FXuGP$VHA9?YeL|+HO(qz$qel4uL zSN}zA7{EvgFGTjXjkzK0#8N_GaDonGc4tNdw;fjcnf7JyA|b>;HqY9T1z##<$K%l7 zH4bZs6|0#neel*Mve5JN`bgvzcE>828cx$?M{|)mBci!FQvs+)drOIQgV%C;buNY zcb=0>Hk1$O8O9*cAUtNF)leDK>Y2;NKG-=2}GF#KK&&Q|Y+I45gNH z--x-SFAgb`ABKI3E1JZNPbC;NMkrLjuE3s5?~XC7_JNXHbSI!`#;#bpq0su@pS4>S zYQu0wMatRA?i`_{Qd5k2%vE7}dlnzWq+P`y+ZdfB_7 zYL&2@dS8AI=to>RK}4I2i?3XUkr%&04jekqZ|DQ@O$ZI5cvk)}R8f1SK#^CSy93g; z;g6mh7Lo)SOP`;0mad-KA@*$72~*nv<;vUB4UHV-K20Pq4JTo^m#>t9$`~aO@Alj= zo49PtfmrbZ2dHM8v^t7{szVK1CP2-myerq^UQ5YV@kyv^98a^&j1q*sQXP;nUWEAM z5H0MvQ}%ZbJ;7UF^`PP7&#{oveVV`xoGC2gu_#^T?P)TAJe#R6pK~)V&NQ?FGan-} zV}OxL1Re@*CUznUpK?4_*(?3&yMY|~&T5{?HK&!L8iNL;EW1YE{D=T;Crrd^8>1hw zKHF7{+8KXEcXI6iT+JNKqn(5Ask_kYZSE{Y85VAj#Wx<$1V-D))f;EglY&}(Tjy$^ z<>`T#G3$Oka%84ceYb3)yNGLVV`Uq!6V!OxHmp1~XKGvgk9Xtdk9}&WnY+R33{U%U z?5@Muf{N<98q1jb2%fIr^=8M!W9S56KO)Q;k@; zW=d5NMn&9f_ z9PYHuR8AR|$psUp1lKlL5gb#=MF@WM!X-CRZtWDNev(0COij*XlDIXnB%?38lTONf4V~V4Y zy>^+%i1_d!Rn^dN>e`G8=@}@?+Q@}VWvHuK*|gw`j$3RWV6Rw zz2NZQ^Pj6RCnpeDajnrXEX^+c!6XRB3;rmNi|uIt&3&%#kkJ12`}(7Twage+nK8j{ z1n@>AWj;r?CZT_eS2QQgi2ng>8VE)_GDq700000exFYUgx{Hh1DNXF3GNOe>WJ}1+3aTdJCJ>pa_tj1^ON;*tc%NKJDx4 z%dPkPTLyYZAh;9fs`1arCw-T2Sxt&q6{{e*oA^SP=Xmua+B(=Xu2e!)Ia&Fxvm#|s zaFDX_Dkg#Z@_N0>JXAK#4;B85sQ`Dy&|4H$w!I3AmFmo~^+4UI0Ug&=>pADK{u%%E z^%X6yc+~i6&hD6`@A}XSCzxdfoq+Qjv_RF?!_qf>q8QEjtj{eTGpD`)RfOtzGJgPgviGKjKchM0l=X z2U8T9+3tv}UYdG%jwc-bnH41LO)gsrwQD1X^vx8VLMMWt^?iB zVqt+mmx;R9v0mo_?&$1!wxL@-9)MP*Jlu%Tbo~P&ohLCuWjFF~S?p^tjPGcn#mC9C z(%x6YfRgA|xwLOSUx}QZ@q3p2sv#~nGyi@Y}bggw|2}}axj_k5xrpC(VF;KD5edJsRsA`87F;|}U ziq%FHtHpX&4~9CRgPmW|c{iXF>;4SQ$qTVDM`Ji$j-t`kYMA|xKsutJeCnn$1uEfo z7YtBRfdJ507%;e`#kz!yTupPt;iLN!#k!r2M#Lvhd|^%u_>nKO>g-NyU@XBO8>ZBU z`;Lp7=fDlZyA)gv+*%=*&~$KQcHhtbT+Vetc71Ns%-^A=J^jk-q2LVh&`vjXOLJ)t zy}Hnsteg^ALRd_C#z4ZJQbaiSkME^0YQQ@%&4>0DT@ z?std{k<*a8T!GK&TA4Ud&xfgcr)0#U<30k@Ir22UAW-OGWDWDeu+rY_BV51-CGmx! z{%^DrIuxCGq<7Yb`|A!8-!@+_X zJ{Q^}ugideG-{+<1x~;Km#h zgyN_U0{vK99OJl8Q;A=7vVeh@6+*=y~if6;QPA8`h8PMm$&il#|0b4p_T@IXM5)+KrBr-KO~O zvQ4VWk0h-qo^$^G{Tma=gM_(-@^cfa3P)w|j8Ov=zWsd{`!bZ#P!inQ{rxSF1N+k8 zJ7jfynhR!!3H~4$OCq%HFyK2;pBNPZl&`NZy~!6qR5jc83b%}#(CCT2?r{2GH5Fdy zzpvf!6s4-OPivn$+!5C8j&A7qbArqq% zi$({l>y9YLa5_)jhKQZp37cJAIXm322KWYkM811|$LXuPH#FL`M!Kz=N^!iWykd&W zB7L~S^`g$e`E47IfeIcTjC2D4i?=S<9ikk7ExT{H?ZJ$4yOcWjN)x5&bCaEu9rt7A zAt^oQyv1~n!ZGXP7?||oVTzshmlfO22bWo2oew^D^;8{;5ToFB^hE7~F)BeVseIC~ zMRrCUjx77pw(FzdSr7=`4UTR^X|XH}$=(%urFJJ%!>nrhESuLq5&+g;l66E3Ll z4u+T=yF^^_-0!+E^+E}L!D8noh`)rSQ9k6TC38X#W?+0wpp~8nIru`xo#_&-vuWD{ zz{|ph;5qtGq%1C^;5^{NNYxd!@B4PSF27?85Mxcp)jv8=F7O*MUuK;w^I4dJQ~0YP z+0Av0UXI4NE(!ktU(Ip@s^2Z2eVoNA{g;L6Y1d~#br5}Uvre-=)4}y&dU0(x9}PWC zKS)4=M~%?CZcMvjkbIg?_VI*MG(acT-4#j*&;m3H?}`}EKlX8j{#Co-FmCO4Y4tC@ z{fKaDQ`IN}oiB0jx{>nqtz%7s**vd=9+L2sB-CKS;1Y7JK9*7S2*XKBpA*0H(-Tfl zp)9PMn3&}yNK@7k_>EPuqQbT7I^fv4fhrc&bpA&xce9B`!Y*4^J4!}ZLA_}FQ0P|k zDMdV3cU+%E^daAnVI_ojNBd^r*agd+1j=hKSy&QW!j@0)qXTN#xwrPuS$&}iZ1`B6 zJUii3M8Gp?HQ9!YJm>H$NtHfe-7yP9R-orh^3CgS@PZJx&`IYmpy(qrqCLkR>Z;|} zpCXOMSI>2C$Zy;m%C0)2Vmrym4`h9maHOdsJ*$=gT=)W#LZGq-v;2!16{2q@iN;@| zv}?<2e70;BOBwPAJpjVF16SjL@=T9)%>y(3$^75{ z{&%FvI;r=)j;%gUdH{no9*$9*vTnGMd=b!i$}R82KFwdg;F;uFG~O%G$VHvH4j8g~ zHKpKue|`eeL4sEX{WpTHV+QLXRt+eFKSnFQmn{0CuzJ$F*~p9%1wcN3?-9@Lz5n~~ zzx};nz->2W-2qS&s|dt0=*OkSrwp`RpSB{M#>mBU4w&~ee|*ZdyUl!A2g*Y9&5Kg? zz)!s&Em{yOp4+`{=wXtB8+AcGbI z);x;9u^tP6r~w_v$eqU+uX~EcP@TH;>Iyx4@nId$t`Fm!+B({wQjZpU(};G1@f2}H zyyL!eQyCC_aj%zw9_Km>XA7g2htLlN`OY{MU0fchr&A*DP`iEBx|!}QUVREjKm8a% zis=;{!P#@aa8tE(ww?QiGY{2u7ayLYQctwnxJ#I#2VFkl-w0fkrmRQGyGDUQatxtqd kvfPoY1pS73(RA$`>hH4nN7Y>obrpEHqf;IGyCjGPc&NQt1ONa4 literal 0 HcmV?d00001 diff --git a/docs/catalog/metals/thumbs/brass.png b/docs/catalog/metals/thumbs/brass.png new file mode 100644 index 0000000000000000000000000000000000000000..28243917bd074dc7ad1c58f362dd18af6bdd9408 GIT binary patch literal 28131 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 O({R%B25YWk4)i~f|Ef0t literal 0 HcmV?d00001 diff --git a/docs/catalog/metals/thumbs/copper.png b/docs/catalog/metals/thumbs/copper.png new file mode 100644 index 0000000000000000000000000000000000000000..09ff8d33185f53b6d95c157a517ee439a7959d2a GIT binary patch literal 18594 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 literal 0 HcmV?d00001 diff --git a/docs/catalog/metals/thumbs/titanium.png b/docs/catalog/metals/thumbs/titanium.png new file mode 100644 index 0000000000000000000000000000000000000000..e0b7e399e281d0af9dee0eb07654fa7da30626c4 GIT binary patch literal 12090 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 literal 0 HcmV?d00001 diff --git a/docs/catalog/metals/titanium.md b/docs/catalog/metals/titanium.md index 0568267..eb5ec53 100644 --- a/docs/catalog/metals/titanium.md +++ b/docs/catalog/metals/titanium.md @@ -1,5 +1,7 @@ # Titanium +![Titanium](thumbs/titanium.png) + ## Identity | Field | Value | @@ -29,3 +31,11 @@ | Base Color | `(0.6, 0.6, 0.6, 1.0)` | | Metallic | 1.0 | | Roughness | 0.3 | + +## Visual (mat-vis) + +| Field | Value | +|---|---| +| Source ID | `ambientcg/Metal049A` | +| Finish | smooth | +| Available Finishes | smooth | diff --git a/docs/catalog/plastics/README.md b/docs/catalog/plastics/README.md index 8e4b814..e050265 100644 --- a/docs/catalog/plastics/README.md +++ b/docs/catalog/plastics/README.md @@ -1,28 +1,30 @@ # Plastics -| Material | Density | Roughness | Metallic | -|---|---|---|---| -| [PEEK](peek.md) | 1.32 g/cm³ | 0.5 | 0.0 | -| [PEEK Unfilled](peek-unfilled.md) | 1.32 g/cm³ | 0.5 | 0.0 | -| [PEEK 30% Glass Filled](peek-GF30.md) | 1.53 g/cm³ | 0.5 | 0.0 | -| [PEEK 30% Carbon Filled](peek-CF30.md) | 1.41 g/cm³ | 0.5 | 0.0 | -| [Victrex PEEK](peek-victrex.md) | 1.32 g/cm³ | 0.5 | 0.0 | -| [Delrin (POM)](delrin.md) | 1.41 g/cm³ | 0.6 | 0.0 | -| [Ultem (PEI)](ultem.md) | 1.27 g/cm³ | 0.5 | 0.0 | -| [PTFE (Teflon)](ptfe.md) | 2.15 g/cm³ | 0.3 | 0.0 | -| [PTFE Reflector Tape](ptfe-reflector.md) | 2.15 g/cm³ | 0.2 | 0.0 | -| [ESR (3M Vikuiti)](esr.md) | 1.0 g/cm³ | 0.1 | 0.0 | -| [Nylon 6](nylon.md) | 1.13 g/cm³ | 0.6 | 0.0 | -| [PLA (Polylactic Acid)](pla.md) | 1.25 g/cm³ | 0.6 | 0.0 | -| [ABS (Acrylonitrile Butadiene Styrene)](abs.md) | 1.05 g/cm³ | 0.6 | 0.0 | -| [PETG](petg.md) | 1.27 g/cm³ | 0.5 | 0.0 | -| [TPU (Thermoplastic Polyurethane)](tpu.md) | 1.21 g/cm³ | 0.7 | 0.0 | -| [Vespel (Polyimide)](vespel.md) | 1.42 g/cm³ | 0.5 | 0.0 | -| [Torlon (PAI)](torlon.md) | 1.45 g/cm³ | 0.5 | 0.0 | -| [PCTFE (Polychlorotrifluoroethylene)](pctfe.md) | 2.13 g/cm³ | 0.4 | 0.0 | -| [PMMA (Acrylic)](pmma.md) | 1.18 g/cm³ | 0.1 | 0.0 | -| [Polyethylene](pe.md) | 0.94 g/cm³ | 0.6 | 0.0 | -| [HDPE (High-Density Polyethylene)](pe-hdpe.md) | 0.95 g/cm³ | 0.6 | 0.0 | -| [LDPE (Low-Density Polyethylene)](pe-ldpe.md) | 0.92 g/cm³ | 0.6 | 0.0 | -| [UHMWPE](pe-uhmwpe.md) | 0.93 g/cm³ | 0.6 | 0.0 | -| [Polycarbonate](pc.md) | 1.2 g/cm³ | 0.15 | 0.0 | +24 materials. Click a name for full properties. + +| Material | Preview | Density | Yield | T_melt | k | +|---|---|---|---|---|---| +| [PEEK](peek.md) | — | 1.32 g/cm³ | 100 MPa | 334 °C | 0.25 W/m·K | +| [PEEK Unfilled](peek-unfilled.md) | — | 1.32 g/cm³ | 100 MPa | 334 °C | 0.25 W/m·K | +| [PEEK 30% Glass Filled](peek-GF30.md) | — | 1.53 g/cm³ | 170 MPa | 334 °C | 0.25 W/m·K | +| [PEEK 30% Carbon Filled](peek-CF30.md) | — | 1.41 g/cm³ | 100 MPa | 334 °C | 0.25 W/m·K | +| [Victrex PEEK](peek-victrex.md) | — | 1.32 g/cm³ | 100 MPa | 334 °C | 0.25 W/m·K | +| [Delrin (POM)](delrin.md) | — | 1.41 g/cm³ | 70 MPa | 165 °C | 0.25 W/m·K | +| [Ultem (PEI)](ultem.md) | — | 1.27 g/cm³ | 90 MPa | 340 °C | 0.22 W/m·K | +| [PTFE (Teflon)](ptfe.md) | — | 2.15 g/cm³ | 20 MPa | 327 °C | 0.24 W/m·K | +| [PTFE Reflector Tape](ptfe-reflector.md) | — | 2.15 g/cm³ | 20 MPa | 327 °C | 0.24 W/m·K | +| [ESR (3M Vikuiti)](esr.md) | — | 1.0 g/cm³ | — | — | — | +| [Nylon 6](nylon.md) | — | 1.13 g/cm³ | 45 MPa | 215 °C | — | +| [PLA (Polylactic Acid)](pla.md) | — | 1.25 g/cm³ | 50 MPa | 160 °C | — | +| [ABS (Acrylonitrile Butadiene Styrene)](abs.md) | — | 1.05 g/cm³ | 40 MPa | 225 °C | — | +| [PETG](petg.md) | — | 1.27 g/cm³ | — | 225 °C | — | +| [TPU (Thermoplastic Polyurethane)](tpu.md) | — | 1.21 g/cm³ | — | — | — | +| [Vespel (Polyimide)](vespel.md) | — | 1.42 g/cm³ | 70 MPa | 400 °C | — | +| [Torlon (PAI)](torlon.md) | — | 1.45 g/cm³ | 110 MPa | 330 °C | — | +| [PCTFE (Polychlorotrifluoroethylene)](pctfe.md) | — | 2.13 g/cm³ | 50 MPa | 217 °C | — | +| [PMMA (Acrylic)](pmma.md) | — | 1.18 g/cm³ | 70 MPa | 160 °C | 0.19 W/m·K | +| [Polyethylene](pe.md) | — | 0.94 g/cm³ | 25 MPa | 130 °C | 0.4 W/m·K | +| [HDPE (High-Density Polyethylene)](pe-hdpe.md) | — | 0.95 g/cm³ | 26 MPa | 130 °C | 0.4 W/m·K | +| [LDPE (Low-Density Polyethylene)](pe-ldpe.md) | — | 0.92 g/cm³ | 10 MPa | 110 °C | 0.4 W/m·K | +| [UHMWPE](pe-uhmwpe.md) | — | 0.93 g/cm³ | 20 MPa | 130 °C | 0.4 W/m·K | +| [Polycarbonate](pc.md) | — | 1.2 g/cm³ | 60 MPa | 267 °C | 0.2 W/m·K | diff --git a/docs/catalog/scintillators/README.md b/docs/catalog/scintillators/README.md index 7fd3f0c..1e5f72a 100644 --- a/docs/catalog/scintillators/README.md +++ b/docs/catalog/scintillators/README.md @@ -1,17 +1,19 @@ # Scintillators -| Material | Density | Roughness | Metallic | +13 materials. Click a name for full properties. + +| Material | Preview | Density | n (IOR) | |---|---|---|---| -| [LYSO](lyso.md) | 7.1 g/cm³ | 0.3 | 0.0 | -| [LYSO:Ce](lyso-Ce.md) | 7.1 g/cm³ | 0.3 | 0.0 | -| [BGO](bgo.md) | 7.13 g/cm³ | 0.3 | 0.0 | -| [NaI](nai.md) | 3.67 g/cm³ | 0.2 | 0.0 | -| [NaI(Tl)](nai-Tl.md) | 3.67 g/cm³ | 0.2 | 0.0 | -| [CsI](csi.md) | 4.51 g/cm³ | 0.25 | 0.0 | -| [CsI(Tl)](csi-Tl.md) | 4.51 g/cm³ | 0.25 | 0.0 | -| [CsI(Na)](csi-Na.md) | 4.51 g/cm³ | 0.25 | 0.0 | -| [LaBr3:Ce](labr3.md) | 5.29 g/cm³ | 0.3 | 0.0 | -| [PWO](pwo.md) | 8.28 g/cm³ | 0.35 | 0.0 | -| [Plastic Scintillator](plastic_scint.md) | 1.032 g/cm³ | 0.4 | 0.0 | -| [BC-400 Plastic Scintillator](plastic_scint-BC400.md) | 1.032 g/cm³ | 0.4 | 0.0 | -| [EJ-200 Plastic Scintillator](plastic_scint-EJ200.md) | 1.032 g/cm³ | 0.4 | 0.0 | +| [LYSO](lyso.md) | — | 7.1 g/cm³ | 1.82 | +| [LYSO:Ce](lyso-Ce.md) | — | 7.1 g/cm³ | 1.82 | +| [BGO](bgo.md) | — | 7.13 g/cm³ | 2.15 | +| [NaI](nai.md) | — | 3.67 g/cm³ | 1.85 | +| [NaI(Tl)](nai-Tl.md) | — | 3.67 g/cm³ | 1.85 | +| [CsI](csi.md) | — | 4.51 g/cm³ | 1.95 | +| [CsI(Tl)](csi-Tl.md) | — | 4.51 g/cm³ | 1.95 | +| [CsI(Na)](csi-Na.md) | — | 4.51 g/cm³ | 1.95 | +| [LaBr3:Ce](labr3.md) | — | 5.29 g/cm³ | 1.9 | +| [PWO](pwo.md) | — | 8.28 g/cm³ | 2.26 | +| [Plastic Scintillator](plastic_scint.md) | — | 1.032 g/cm³ | — | +| [BC-400 Plastic Scintillator](plastic_scint-BC400.md) | — | 1.032 g/cm³ | — | +| [EJ-200 Plastic Scintillator](plastic_scint-EJ200.md) | — | 1.032 g/cm³ | — | diff --git a/scripts/enrich_vis.py b/scripts/enrich_vis.py index 48c8cc4..01408de 100644 --- a/scripts/enrich_vis.py +++ b/scripts/enrich_vis.py @@ -1,20 +1,13 @@ #!/usr/bin/env python3 """Propose [vis] mappings for materials that don't have one. -Queries the mat-vis index via pymat.vis.search() and suggests -best-match appearances for each TOML-registered material. +Uses tag-based matching against the mat-vis index — the ambientcg +and polyhaven tags ("brushed", "silver", "oak", "concrete", etc.) +give far better signal than category alone. Usage: - # Preview proposed mappings - python scripts/enrich_vis.py - - # Write proposed TOML patches to a file - python scripts/enrich_vis.py --output proposed_vis.toml - - # Auto-apply top matches (use with care — review the PR) - python scripts/enrich_vis.py --apply - -Called by .github/workflows/enrich-vis.yml on each mat-vis release. + python scripts/enrich_vis.py # preview + python scripts/enrich_vis.py -o proposed.toml """ from __future__ import annotations @@ -23,57 +16,114 @@ import sys from pathlib import Path -# Ensure src/ is importable when running from repo root sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) from pymat import load_all, vis -def _category_hint(material) -> str | None: - """Infer a mat-vis category from the material's data.""" - # Try the material's TOML key path for hints - key = getattr(material, "_key", "") or "" - name = material.name.lower() - - hints = { - "metal": ["steel", "aluminum", "copper", "brass", "titanium", "tungsten", "lead", "iron"], - "wood": ["wood", "plywood", "mdf", "balsa"], - "plastic": ["peek", "delrin", "nylon", "pla", "abs", "petg", "ptfe", "pmma"], - "ceramic": ["alumina", "macor", "zirconia"], - "glass": ["glass"], - "concrete": ["concrete"], - "stone": ["stone", "rock", "marble", "granite"], - } - - for category, keywords in hints.items(): - if any(kw in name or kw in key for kw in keywords): - return category - return None +# Per-material tag heuristics — richer than category-only matching +# Each tuple: (category, required_tags_to_try_in_order) +# Multiple tag sets are tried; first non-empty result wins. +MATERIAL_HINTS: dict[str, tuple[str, list[list[str]]]] = { + # Metals — finish matters + "stainless": ("metal", [["brushed", "silver", "steel"], ["silver", "steel"], ["metal"]]), + "s304": ("metal", [["brushed", "silver", "steel"], ["silver", "steel"]]), + "s316L": ("metal", [["brushed", "silver", "steel"], ["silver", "steel"]]), + "s303": ("metal", [["brushed", "silver", "steel"], ["silver", "steel"]]), + "s17_4PH": ("metal", [["brushed", "silver", "steel"], ["silver", "steel"]]), + "electropolished": ("metal", [["clean", "silver", "smooth"], ["smooth", "silver"]]), + "passivated": ("metal", [["brushed", "silver"], ["silver", "steel"]]), + "aluminum": ("metal", [["clean", "silver"], ["silver", "metal"]]), + "a6061": ("metal", [["clean", "silver"], ["silver"]]), + "a7075": ("metal", [["clean", "silver"], ["silver"]]), + "a2024": ("metal", [["clean", "silver"], ["silver"]]), + "a6063": ("metal", [["clean", "silver"], ["silver"]]), + "copper": ("metal", [["copper", "clean", "shiny"], ["copper"]]), + "OFHC": ("metal", [["copper", "clean"], ["copper"]]), + "brass": ("metal", [["brass", "gold"], ["bronze"], ["copper", "gold"]]), + "tungsten": ("metal", [["iron"], ["metal"]]), + "pure": ("metal", [["iron"], ["metal"]]), + "W90": ("metal", [["iron"], ["metal"]]), + "lead": ("metal", [["grey", "metal", "smooth"], ["metal"]]), + "titanium": ("metal", [["clean", "silver"], ["silver"]]), + "grade5": ("metal", [["clean", "silver"], ["silver"]]), + # Plastics — mostly matte, colored + "peek": ("plastic", [["plastic"]]), + "delrin": ("plastic", [["plastic"]]), + "nylon": ("plastic", [["plastic"]]), + "pla": ("plastic", [["plastic"]]), + "abs": ("plastic", [["plastic"]]), + "petg": ("plastic", [["plastic"]]), + "ptfe": ("plastic", [["white", "plastic"], ["plastic"]]), + "pmma": ("plastic", [["plastic"]]), + "pe": ("plastic", [["plastic"]]), + "pc": ("plastic", [["plastic"]]), + "ultem": ("plastic", [["plastic"]]), + "torlon": ("plastic", [["plastic"]]), + "vespel": ("plastic", [["plastic"]]), + "tpu": ("plastic", [["plastic"]]), + "pctfe": ("plastic", [["plastic"]]), + "esr": ("plastic", [["white"], ["plastic"]]), + # Ceramics + "alumina": ("ceramic", [["white", "clean"], ["ceramic"]]), + "macor": ("ceramic", [["white"], ["ceramic"]]), + "zirconia": ("ceramic", [["white"], ["ceramic"]]), + "glass": ("ceramic", [["glass"]]), # fallback since glass is rare +} + + +def _hints_for(key: str, name: str) -> tuple[str | None, list[list[str]]]: + """Return (category, list of tag sets to try in order).""" + key_lower = key.lower() + name_lower = name.lower() + + for k, (cat, tag_sets) in MATERIAL_HINTS.items(): + if k.lower() in key_lower or k.lower() in name_lower: + return cat, tag_sets + + # Generic category hints as fallback + if any(w in name_lower for w in ["steel", "iron", "alloy"]): + return "metal", [["silver", "steel"], ["metal"]] + if any(w in name_lower for w in ["wood", "ply", "mdf"]): + return "wood", [["wood"]] + if "concrete" in name_lower: + return "concrete", [["concrete"]] + if any(w in name_lower for w in ["stone", "rock", "marble"]): + return "stone", [["stone"]] + + return None, [[]] def propose_mappings(limit_per_material: int = 3) -> list[dict]: - """Generate vis mapping proposals for unmapped materials.""" + """Generate vis mapping proposals using tag-based matching.""" materials = load_all() proposals = [] for key, mat in materials.items(): - # Skip if already has vis mapping if mat.vis.source_id is not None: continue - category = _category_hint(mat) - pbr = mat.properties.pbr - - try: - candidates = vis.search( - category=category, - roughness=pbr.roughness if pbr.roughness != 0.5 else None, - metalness=pbr.metallic if pbr.metallic != 0.0 else None, - limit=limit_per_material, - ) - except ConnectionError: + category, tag_sets = _hints_for(key, mat.name) + if not category: continue + # Try tag sets in order, first match wins + candidates = [] + tags_used = None + for tags in tag_sets: + try: + results = vis.search( + category=category, + tags=tags if tags else None, + limit=limit_per_material, + ) + except ConnectionError: + continue + if results: + candidates = results + tags_used = tags + break + if not candidates: continue @@ -85,9 +135,8 @@ def propose_mappings(limit_per_material: int = 3) -> list[dict]: proposals.append({ "material_key": key, "material_name": mat.name, - "category_hint": category, - "pbr_roughness": pbr.roughness, - "pbr_metallic": pbr.metallic, + "category": category, + "tags_matched": tags_used, "candidates": candidates, }) @@ -96,22 +145,19 @@ def propose_mappings(limit_per_material: int = 3) -> list[dict]: def format_toml(proposals: list[dict]) -> str: """Format proposals as TOML [vis] sections.""" - lines = ["# Auto-generated vis mapping proposals", "# Review before merging", ""] + lines = ["# Auto-generated vis mapping proposals (tag-based matching)", ""] for p in proposals: key = p["material_key"] top = p["candidates"][0] alts = p["candidates"][1:] - lines.append(f"# {p['material_name']} (category: {p['category_hint']})") - lines.append(f"# roughness={p['pbr_roughness']}, metallic={p['pbr_metallic']}") + lines.append(f"# {p['material_name']} — matched on tags {p['tags_matched']}") + lines.append(f"# top tags: {', '.join(top.get('tags', [])[:6])}") if alts: lines.append(f"# alternatives: {[c['id'] for c in alts]}") - lines.append(f'[{key}.vis]') - lines.append(f'default = "auto"') - lines.append(f'') - lines.append(f'[{key}.vis.finishes]') - lines.append(f'auto = "{top["id"]}"') + lines.append(f"[{key}.vis.finishes]") + lines.append(f'default = "{top["id"]}"') lines.append("") return "\n".join(lines) @@ -120,7 +166,6 @@ def format_toml(proposals: list[dict]) -> str: def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", "-o", help="Write proposals to file") - parser.add_argument("--apply", action="store_true", help="Apply top matches (not yet implemented)") args = parser.parse_args() proposals = propose_mappings() @@ -133,13 +178,10 @@ def main(): if args.output: Path(args.output).write_text(toml_text) - print(f"Wrote {len(proposals)} proposals to {args.output}") + print(f"Wrote {len(proposals)} proposals to {args.output}", file=sys.stderr) else: print(toml_text) - print(f"\n# {len(proposals)} materials proposed") - - if args.apply: - print("--apply not yet implemented. Review the proposals and add to TOML manually.") + print(f"\n# {len(proposals)} materials proposed", file=sys.stderr) if __name__ == "__main__": diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py index 18bbfdb..ae72ea4 100644 --- a/scripts/generate_catalog.py +++ b/scripts/generate_catalog.py @@ -171,30 +171,110 @@ def _material_page(mat, thumb_path: str | None, category: str) -> str: return "\n".join(lines) +def _fmt_density(mat) -> str: + d = mat.properties.mechanical.density + return f"{d} g/cm³" if d else "—" + + +def _fmt_yield(mat) -> str: + y = mat.properties.mechanical.yield_strength + return f"{y} MPa" if y else "—" + + +def _fmt_tensile(mat) -> str: + t = mat.properties.mechanical.tensile_strength + return f"{t} MPa" if t else "—" + + +def _fmt_modulus(mat) -> str: + e = mat.properties.mechanical.youngs_modulus + return f"{e} GPa" if e else "—" + + +def _fmt_melting(mat) -> str: + mp = mat.properties.thermal.melting_point + return f"{mp} °C" if mp is not None else "—" + + +def _fmt_k(mat) -> str: + k = mat.properties.thermal.thermal_conductivity + return f"{k} W/m·K" if k else "—" + + +def _fmt_ior(mat) -> str: + n = mat.properties.optical.refractive_index + return f"{n}" if n else "—" + + +# Per-category columns chosen by audience +# Metals: mechanical + thermal (engineering primary) +# Plastics: density + thermal (operating temp matters) +# Scintillators: density + optical (physics primary) +# Gases/liquids: density + thermal +# Default: density + mechanical +_CATEGORY_COLUMNS: dict[str, list[tuple[str, callable]]] = { + "metals": [ + ("Density", _fmt_density), + ("Yield", _fmt_yield), + ("Tensile", _fmt_tensile), + ("E", _fmt_modulus), + ("T_melt", _fmt_melting), + ], + "plastics": [ + ("Density", _fmt_density), + ("Yield", _fmt_yield), + ("T_melt", _fmt_melting), + ("k", _fmt_k), + ], + "scintillators": [ + ("Density", _fmt_density), + ("n (IOR)", _fmt_ior), + ], + "ceramics": [ + ("Density", _fmt_density), + ("E", _fmt_modulus), + ("T_melt", _fmt_melting), + ], + "electronics": [ + ("Density", _fmt_density), + ], + "liquids": [ + ("Density", _fmt_density), + ("n (IOR)", _fmt_ior), + ], + "gases": [ + ("Density", _fmt_density), + ], +} + + def _category_index(category: str, materials: list, has_thumbnails: bool) -> str: - """Generate markdown index for a category.""" + """Generate markdown index for a category — columns tuned per audience.""" lines = [f"# {category.title()}", ""] + lines.append(f"{len(materials)} materials. Click a name for full properties.") + lines.append("") + cols = _CATEGORY_COLUMNS.get(category, [("Density", _fmt_density)]) + + # Header + header = ["Material"] if has_thumbnails: - lines.append("| Material | Thumbnail | Density | Roughness | Metallic |") - lines.append("|---|---|---|---|---|") - else: - lines.append("| Material | Density | Roughness | Metallic |") - lines.append("|---|---|---|---|") + header.append("Preview") + header.extend(c[0] for c in cols) + lines.append("| " + " | ".join(header) + " |") + lines.append("|" + "|".join(["---"] * len(header)) + "|") + # Rows for mat, key in materials: - mech = mat.properties.mechanical - pbr = mat.properties.pbr - density = f"{mech.density} g/cm³" if mech.density else "—" - link = f"[{mat.name}]({key}.md)" - - if has_thumbnails and mat.vis.source_id: - thumb = f"![thumb](thumbs/{key}.png)" - lines.append(f"| {link} | {thumb} | {density} | {pbr.roughness} | {pbr.metallic} |") - elif has_thumbnails: - lines.append(f"| {link} | — | {density} | {pbr.roughness} | {pbr.metallic} |") - else: - lines.append(f"| {link} | {density} | {pbr.roughness} | {pbr.metallic} |") + row = [f"[{mat.name}]({key}.md)"] + if has_thumbnails: + if (THUMBS_EXIST := (mat.vis.source_id is not None)): + row.append(f"![]({'thumbs/' + key + '.png'})") + else: + row.append("—") + for _, fmt in cols: + row.append(fmt(mat)) + lines.append("| " + " | ".join(row) + " |") lines.append("") return "\n".join(lines) diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index d8d3143..a00c9dc 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -31,8 +31,9 @@ transmission = 0.0 default = "brushed" [stainless.vis.finishes] -brushed = "ambientcg/Metal032" -polished = "ambientcg/Metal012" +brushed = "ambientcg/Metal012" +polished = "ambientcg/Metal049A" +dirty = "ambientcg/Metal049B" # Stainless Steel 304 [stainless.s304] @@ -146,6 +147,11 @@ base_color = [0.88, 0.88, 0.88, 1.0] metallic = 1.0 roughness = 0.4 transmission = 0.0 +default = "smooth" + +[aluminum.vis.finishes] +smooth = "ambientcg/Metal049A" +machined = "ambientcg/Metal055A" # Aluminum 6061-T6 [aluminum.a6061] @@ -279,6 +285,12 @@ base_color = [0.72, 0.45, 0.2, 1.0] metallic = 1.0 roughness = 0.3 transmission = 0.0 +default = "polished" + +[copper.vis.finishes] +polished = "ambientcg/Metal043A" +oxidized = "ambientcg/Metal043B" +aged = "ambientcg/Metal026" [copper.OFHC] name = "OFHC Copper (Oxygen-Free High Conductivity)" @@ -400,6 +412,10 @@ base_color = [0.6, 0.6, 0.6, 1.0] metallic = 1.0 roughness = 0.3 transmission = 0.0 +default = "smooth" + +[titanium.vis.finishes] +smooth = "ambientcg/Metal049A" # ============================================================================ @@ -427,6 +443,11 @@ base_color = [0.88, 0.78, 0.5, 1.0] metallic = 1.0 roughness = 0.25 transmission = 0.0 +default = "polished" + +[brass.vis.finishes] +polished = "ambientcg/Metal008" +oxidized = "ambientcg/Metal035" # ============================================================================ diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index 45126bc..7183000 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -36,12 +36,20 @@ def search( *, category: str | None = None, + tags: list[str] | None = None, roughness: float | None = None, metalness: float | None = None, source: str | None = None, limit: int = 20, ) -> list[dict[str, Any]]: - """Search the mat-vis index by category and scalar similarity. + """Search the mat-vis index by category, tags, and scalar similarity. + + Args: + category: filter by canonical category (metal, wood, stone, ...) + tags: require ALL these tags to be present in the entry's tags list + roughness / metalness: score by scalar distance (if set in index) + source: limit to one source + limit: max results Does NOT filter by tier — search is for finding materials, tier is a fetch-time concern. @@ -58,6 +66,8 @@ def search( if metalness is not None: metalness_range = (max(0.0, metalness - 0.2), min(1.0, metalness + 0.2)) + required_tags = set(t.lower() for t in (tags or [])) + # Search all sources, no tier filter sources = [source] if source else client.sources() results: list[dict] = [] @@ -66,6 +76,9 @@ def search( for entry in client.index(src): if category and entry.get("category") != category: continue + entry_tags = set(t.lower() for t in entry.get("tags", [])) + if required_tags and not required_tags.issubset(entry_tags): + continue if roughness_range and not ( entry.get("roughness") is not None and roughness_range[0] <= entry["roughness"] <= roughness_range[1]