diff --git a/versioning/.gitignore b/versioning/.gitignore
new file mode 100644
index 0000000..75c6182
--- /dev/null
+++ b/versioning/.gitignore
@@ -0,0 +1,3 @@
+__pycache__/
+*.pyc
+.pytest_cache/
diff --git a/versioning/VERSIONING.md b/versioning/VERSIONING.md
new file mode 100644
index 0000000..53f396b
--- /dev/null
+++ b/versioning/VERSIONING.md
@@ -0,0 +1,452 @@
+# OpenPBR Version Conversion
+
+`openpbr_version.py` converts an **OpenPBR Surface** parameter set between
+specification versions, in either direction, choosing the parameter
+correspondence that produces the **closest match of the resulting
+appearance**.
+
+It is both a Python API and a command-line tool, and is architected so that
+each new release (1.3, …) is added as a single *adjacent* migration step; any
+multi-version conversion is then composed automatically.
+
+---
+
+## 1. What a version converter can and cannot do
+
+A material's appearance is determined by its parameters **and** by the fixed
+shading math of the version that renders it. A version converter only changes
+parameters, so it can only compensate for differences that are themselves
+*parameterized*. Every difference between two versions therefore falls into one
+of three classes:
+
+| Class | Meaning | Converter action |
+|-------|---------|------------------|
+| **A — Exactly invertible** | A pure reparametrization. After the remap the appearance is identical. | Apply the closed-form map. |
+| **B — Lossy / bounded** | Mappable over part of the parameter domain; outside it the two versions genuinely diverge. | Map where possible; clamp + **warn** elsewhere. |
+| **C — Not parameter-correctable** | An internal change to the BSDF or the reference MaterialX graph. No parameter controls it, so no remap can undo it. | Make **no** parameter change; emit a **note**. |
+
+The converter reports Class-B divergence as `warnings` and Class-C divergence
+as `notes` on the `ConversionResult`. A clean (warning-free, note-free) result
+means the conversion is exact for that material.
+
+> **Key consequence.** "Closest appearance match" is an *honest* goal, not a
+> perfect one. Two materials that differ only by Class-A parameters render
+> identically across versions; materials that exercise Class B or C will differ,
+> and the converter tells you exactly where and why.
+
+---
+
+## 2. Architecture
+
+### The version ladder
+
+Versions are linearly ordered in `VERSION_ORDER`:
+
+```python
+VERSION_ORDER = ("1.1", "1.2") # extend with "1.3", …
+```
+
+Because the ladder is linear, the path between any two versions is an
+unambiguous walk up or down — no graph search is needed.
+
+### Adjacent migrations
+
+Each *adjacent* pair is described by one `Migration`, holding both directions:
+
+```python
+@dataclass
+class Migration:
+ older: str
+ newer: str
+ upgrade: object # rule(params, out, warnings, notes): older -> newer
+ downgrade: object # rule(params, out, warnings, notes): newer -> older
+
+MIGRATIONS = [
+ Migration("1.1", "1.2", _upgrade_1_1_to_1_2, _downgrade_1_2_to_1_1),
+]
+```
+
+A **rule** is a callable `rule(params, out, warnings, notes)` that reads the
+source `params` dict, mutates `out` (a shallow copy of `params`, which starts as
+a pass-through of every parameter), and appends any `warnings` / `notes`.
+
+### Composition
+
+`convert_params` builds the ordered list of adjacent steps with `_build_chain`
+and applies them in sequence, feeding the parameter set produced by each step
+into the next:
+
+```
+1.1 → 1.3 == apply(_upgrade_1_1_to_1_2) then apply(_upgrade_1_2_to_1_3)
+1.3 → 1.1 == apply(_downgrade_1_3_to_1_2) then apply(_downgrade_1_2_to_1_1)
+```
+
+When more than one hop runs, each warning/note is prefixed with its hop label
+(e.g. `[1.2→1.3]`) so multi-version diagnostics stay attributable. Single-hop
+output is unprefixed.
+
+The intermediate parameter set after step *n* is, by construction, a valid
+parameter set in the intermediate version, so step *n+1*'s defaults and rules
+apply correctly.
+
+### Two layers
+
+* **`convert_params(params, from_version, to_version)`** — the dependency-free
+ core. Operates on a plain `{name: value}` dict (values are floats or
+ 3-sequences for `color3`). Imports only the standard library, so any
+ application or renderer can embed it.
+* **`convert(input_path=…/input_string=…, from_version, to_version, output_path=…)`**
+ — a MaterialX `.mtlx` document wrapper (requires the `MaterialX` package).
+ It reads the `open_pbr_surface` shader instance, runs the core, and writes the
+ converted parameters back, touching only the inputs the conversion changed.
+
+### Valued vs. connected inputs
+
+Whether a parameter is a literal **value** or **connected** to another node is
+never something the caller specifies — the two layers handle it differently:
+
+* The **core** has no concept of connections. It sees only values, so a
+ connected (texture-driven) parameter is simply *absent from the dict*.
+ Connection handling is the integrating application's concern.
+* The **wrapper** reads connectivity *from the document*: a MaterialX ``
+ is "valued" if it has a `value=` attribute and "connected" if it instead has
+ `nodename=` / `nodegraph=` / `output=` / `interfacename=` (then
+ `getValueString()` is empty). Connected inputs are passed through untouched.
+
+Most maps need no special handling for connected inputs, but the **coupled**
+maps (whose result depends on *another* parameter) do:
+
+* **`emission_weight` (1.1 → 1.2)** must be authored as the constant `1` even
+ when `emission_luminance` is connected, or the textured emission goes dark
+ (the new weight defaults to 0). `weight = 1` is exact regardless of how
+ luminance is driven, so the wrapper detects a connected `emission_luminance`
+ and sets it (with a warning). The valued case is handled by the core.
+* **`transmission_scatter` (the `Ω = −S/ln T` albedo remap)** depends on
+ `transmission_color`. If either input is node-driven, the result cannot be
+ reduced to a constant; reproducing it exactly requires inserting the
+ computation as nodes, which the wrapper does **not** do. Such cases are left
+ to the application (insert a sub-graph, or approximate and flag).
+
+---
+
+## 3. The 1.1 ↔ 1.2 mapping reference
+
+This is the authoritative per-parameter logic, derived by diffing the `v1.1`
+spec tag against the 1.2 spec (not the changelog, which omits #254). Four
+parameters were **added** in 1.2 and **none removed**; two shared parameters
+were semantically **reinterpreted**; one default changed.
+
+### 3.1 Emission — `emission_weight` (#231) + `emission_luminance` default 0 → 1000 · Class A
+
+The emitted radiance is
+
+```
+1.1: emission_color · emission_luminance
+1.2: emission_weight · emission_color · emission_luminance
+```
+
+In 1.2 `emission_weight` **defaults to 0**, and `emission_luminance`'s default
+moved 0 → 1000. The two changes cancel for an *unauthored* material (both
+non-emissive), so no action is needed when emission is unauthored. For authored
+emission the product is what matters:
+
+* **1.1 → 1.2:** `emission_weight = 1`, `emission_luminance` unchanged. *(Must
+ set the weight — leaving it at the 1.2 default of 0 would zero the emission.)*
+* **1.2 → 1.1:** `emission_luminance ← emission_weight × emission_luminance`;
+ drop `emission_weight`.
+
+Both directions are exact and always in range. (Going down, an HDR
+`emission_color > 1` is outside 1.1's `[0,1]` range and is warned.)
+
+### 3.2 `transmission_scatter` — coefficient → albedo (#286) · Class B
+
+Both versions share the extinction `μ_t = −ln(T)/λ` from `transmission_color`
+*T* and `transmission_depth` *λ*. The scatter parameter sets the scattering
+coefficient `μ_s` differently:
+
+```
+1.1: μ_s = S / λ (S is a coefficient, "× 1/depth")
+1.2: μ_s = Ω · μ_t = −Ω·ln(T)/λ (Ω is the single-scattering albedo)
+```
+
+Matching `μ_s` (λ cancels — the map depends only on `transmission_color`):
+
+* **1.2 → 1.1:** `S = −Ω · ln(T)` per channel. Keeps 1.1's grey-shift
+ untriggered, so **exact** — but `S` can exceed 1 for dark `transmission_color`.
+ The exact value is emitted (1.1 predates the #277 input clamp) with a
+ range-overflow warning.
+* **1.1 → 1.2:** `Ω = −S / ln(T)` per channel. Exact when `S ≤ −ln(T)`. When
+ `S > −ln(T)` (1.1's grey-shift regime, `μ_s > μ_t`), no exact 1.2 form exists
+ → clamp `Ω = 1` and warn.
+
+Edge cases: `transmission_color = 1` (no extinction) — 1.1 can produce a purely
+scattering medium that 1.2 cannot represent (warn; albedo 0). Scatter warnings
+are suppressed when transmission is inactive (`transmission_weight = 0` or
+`transmission_depth = 0`), since the medium is then irrelevant.
+
+### 3.3 `specular_weight` — refraction decoupling (#247, #238) · Class B
+
+In 1.1 the `specular_weight`-modulated IOR drove **both** the Fresnel factor and
+the **refraction direction**. In 1.2 it drives the Fresnel only; refraction uses
+the unmodulated `specular_ior`.
+
+* **Opaque** dielectrics: zero difference (only the reflection Fresnel matters,
+ computed identically). Pass through.
+* **Transmissive** dielectrics with `specular_weight ≠ 1`: 1.1 couples the two,
+ so no single `(specular_ior, specular_weight)` reproduces 1.2's decoupling.
+ Closest match = **pass `specular_weight` through unchanged** (this preserves
+ the reflection highlight, the dominant effect) and **warn** that the refraction
+ direction will not match exactly.
+
+The #238 clamp `min(ξ·F₀, 1)` only affects `specular_weight > 1`, outside the
+valid `[0,1]` range.
+
+### 3.4 New inert lobes — `specular_haze` / `specular_haze_spread` (#254), `specular_retroreflectivity` (#255)
+
+All default to zero weight, so they contribute nothing at default.
+
+* **1.1 → 1.2:** omit (the 1.2 defaults are inert — Class A).
+* **1.2 → 1.1:** drop. If non-default, **warn** (this look has no 1.1
+ equivalent — Class B). `specular_haze_spread` is dropped silently once the
+ haze weight is gone.
+
+> Note: `specular_haze` (#254) is present in the 1.2 spec but missing from the
+> changelog.
+
+### 3.5 Class-C changes (no parameter remap)
+
+Reported as notes, never as parameter edits:
+
+| PR | Change | When it shows |
+|----|--------|---------------|
+| #253 | Coat-darkening chromaticity fix | coat active **and** `coat_color` non-white (note emitted) |
+| #256 | F82 negative-Fresnel clamp | extreme metal parameters |
+| #277 | Universal input clamping | inputs outside their valid ranges |
+| #240, #251, #250, #292, #293 | Reference MaterialX-graph fixes | renderers using the reference graph |
+
+---
+
+## 4. Plugin author's guide — connected inputs
+
+This section is the source of truth for renderer/DCC plugin authors (Arnold
+`mtoa`/`htoa`, etc.) who implement version upgrade/downgrade natively.
+
+A node's parameters are frequently **connected** to upstream node outputs, so
+their values are not known until render time. You therefore cannot always
+"compute the new value." The right mental model is:
+
+> **A version migration is a per-parameter *transform*. If the input carries a
+> literal value, you *fold* the transform to a new constant. If the input is
+> *connected*, you realize the same transform as a small node network inserted
+> between the upstream output and the shader input.**
+
+Folding and network-insertion are two realizations of the *same* math. The
+`convert_params` core does the fold; the math below is what you insert when the
+input is connected.
+
+### Operation kinds
+
+Every parameter migration is one of five operations. Only **TRANSFORM** ever
+requires a network:
+
+| Op | If the input is **valued** | If the input is **connected** |
+|----|----------------------------|-------------------------------|
+| **PASS** | leave as-is | leave as-is |
+| **SET_CONST** | write the constant | write the constant (no upstream involved) |
+| **TRANSFORM** | evaluate the expression numerically | **insert nodes computing the expression**, then rewire |
+| **DROP** | remove the input | disconnect, then remove the input |
+| **RENAME** | move the value to the new name | move the *connection* to the new name |
+
+### 1.1 ↔ 1.2 operation table
+
+`expr` is over the **source** inputs.
+
+| Parameter | 1.1 → 1.2 | 1.2 → 1.1 | Needs a network when connected? |
+|-----------|-----------|-----------|---------------------------------|
+| `specular_weight`, and all unaffected params | PASS | PASS | never |
+| `emission_weight` | **SET_CONST = 1** | DROP | never (a new constant factor) |
+| `emission_luminance` | PASS | **TRANSFORM** `L = emission_weight · emission_luminance` | yes (down) |
+| `transmission_scatter` | **TRANSFORM** `Ω = S / (−ln T)`, clamp [0,1] | **TRANSFORM** `S = Ω · (−ln T)` | yes (both directions) |
+| `specular_haze`, `specular_haze_spread`, `specular_retroreflectivity` | — (inert defaults) | DROP (+ warn if non-default) | yes → just disconnect |
+
+`T` = `transmission_color`, `S`/`Ω` = `transmission_scatter`.
+
+### The two TRANSFORM networks (MaterialX stdlib nodes)
+
+Port these to your renderer's equivalent shading nodes. All of `ln`, `multiply`,
+`divide`, `subtract`, `clamp` exist in the MaterialX standard library.
+
+**`transmission_scatter`, 1.1 → 1.2** — `Ω = S / (−ln T)`:
+
+```
+ln_T = in = transmission_color # natural log, per channel
+negln = in1 = ln_T, in2 = -1
+omega = in1 = transmission_scatter, in2 = negln
+omegaC = in = omega, low = 0, high = 1
+ → wire omegaC into open_pbr_surface.transmission_scatter
+```
+
+⚠️ Guard `T → 1` (white channel): `−ln T → 0`, so `μ_t = 0` and the divide is
+singular. Force `Ω = 0` there (clamp the denominator away from 0, or branch).
+
+**`transmission_scatter`, 1.2 → 1.1** — `S = Ω · (−ln T)`:
+
+```
+ln_T = in = transmission_color
+negln = in1 = ln_T, in2 = -1
+S = in1 = transmission_scatter, in2 = negln
+ → wire S into open_pbr_surface.transmission_scatter (no clamp; 1.1 has none)
+```
+
+**`emission_luminance`, 1.2 → 1.1** — `L = emission_weight · emission_luminance`:
+
+```
+L = in1 = , in2 =
+ → wire L into open_pbr_surface.emission_luminance ; remove the emission_weight input
+```
+
+Each `in*` is the constant if that input was valued, or the upstream output if
+it was connected — one `multiply` covers all valued/connected combinations.
+
+### Connected inputs degrade warnings to render-time clamps
+
+The Class-B safeguards that the value path reports as **author-time warnings**
+become **in-graph clamps** for a connected input, because the values are dynamic:
+
+* the `transmission_scatter` `clamp(0,1)` node silently absorbs the 1.1
+ grey-shift regime per texel — you cannot warn "this texture exceeds the
+ single-scattering range" up front; the clamp node *is* the closest match;
+* likewise the `T → 1` guard.
+
+So the rule to follow is: **fold when you can read a constant; otherwise insert
+the network — and accept that bounded-loss (Class B) warnings become in-graph
+clamps.** Class-C differences (§3.5) are unaffected either way: no network can
+address them.
+
+### Recipe
+
+```
+for each shader input:
+ look up its operation in the table above
+ if the input is VALUED: apply the value-path result from convert_params()
+ if the input is CONNECTED: PASS/DROP/SET_CONST directly, or for TRANSFORM
+ insert the network above and rewire
+surface any Class-B warnings and Class-C notes to the user
+```
+
+---
+
+## 5. Adding a new version (e.g. 1.3)
+
+When 1.3 ships, complete this checklist — **no other code needs to change**;
+multi-hop conversions (1.1 ↔ 1.3) compose automatically.
+
+1. **Diff the spec**, don't trust the changelog. From the repo root:
+ ```bash
+ git show v1.2:index.html | grep -E '^\*\*`[a-z_]+`\*\*' | sort > /tmp/p12.txt
+ git show v1.3:index.html | grep -E '^\*\*`[a-z_]+`\*\*' | sort > /tmp/p13.txt
+ diff /tmp/p12.txt /tmp/p13.txt # added / removed / changed rows
+ ```
+ Classify every difference as A, B, or C (see §1).
+
+2. **Extend the ladder** in `openpbr_version.py`:
+ ```python
+ VERSION_ORDER = ("1.1", "1.2", "1.3")
+ ```
+
+3. **Add `DEFAULTS["1.3"]`** with any parameters the 1.2 ↔ 1.3 rules read for
+ unauthored values (new parameters and any whose default changed).
+
+4. **Write the two rule functions** `_upgrade_1_2_to_1_3` and
+ `_downgrade_1_3_to_1_2`, following the existing pattern: read `params`,
+ mutate `out`, append `warnings` (Class B) / `notes` (Class C). For Class-A
+ reparametrizations, derive the closed-form map and its inverse; for new inert
+ lobes, omit going up and drop-with-warning going down.
+
+5. **Register the migration:**
+ ```python
+ MIGRATIONS = [
+ Migration("1.1", "1.2", _upgrade_1_1_to_1_2, _downgrade_1_2_to_1_1),
+ Migration("1.2", "1.3", _upgrade_1_2_to_1_3, _downgrade_1_3_to_1_2),
+ ]
+ ```
+
+6. **Add `PARAM_TYPES`** entries for any new parameters the MaterialX wrapper may
+ add or remove (so it knows the `float` / `color3` type).
+
+7. **Test:** add per-map numeric tests, both round-trips
+ (1.2 → 1.3 → 1.2 and the reverse), and edge/clamp cases. Document this
+ mapping in §3, and — if any map is a **TRANSFORM** (a value that depends on
+ another parameter) — add its operation row and node network to §4 so plugin
+ authors know how to handle the connected case.
+
+---
+
+## 6. API usage
+
+### Core (dependency-free)
+
+```python
+from openpbr_version import convert_params
+
+res = convert_params({"emission_luminance": 500.0},
+ from_version="1.1", to_version="1.2")
+res.success # True
+res.params_out # {'emission_luminance': 500.0, 'emission_weight': 1.0}
+res.warnings # [] (Class B)
+res.notes # [] (Class C)
+```
+
+`ConversionResult` fields: `success`, `error`, `from_version`, `to_version`,
+`material_name`, `params_in`, `params_out`, `warnings`, `notes`, `output_xml`,
+`output_path`.
+
+### MaterialX document
+
+```python
+from openpbr_version import convert
+
+res = convert(input_path="glass.mtlx", from_version="1.2", to_version="1.1")
+if res.success:
+ print(res.output_path) # glass_v1.1.mtlx
+ for w in res.warnings:
+ print("warn:", w)
+else:
+ print(res.error)
+
+# In-memory:
+res = convert(input_string=xml, from_version="1.1", to_version="1.2")
+res.output_xml # converted MaterialX XML
+```
+
+The wrapper leaves every parameter the conversion did not touch exactly as
+authored, and requires an explicit `from_version` (a `.mtlx` document records
+the MaterialX spec version, not the OpenPBR version, so there is nothing to
+auto-detect).
+
+---
+
+## 7. Command-line usage
+
+```bash
+# Upgrade a material 1.1 -> 1.2 (writes glass_v1.2.mtlx)
+python openpbr_version.py glass.mtlx --from 1.1 --to 1.2
+
+# Downgrade to an explicit output path
+python openpbr_version.py glass.mtlx --from 1.2 --to 1.1 -o glass_legacy.mtlx
+```
+
+The CLI prints the changed/added/dropped parameters, Class-B warnings, Class-C
+notes, and the output path. Exit code is non-zero on error.
+
+---
+
+## 8. Tests
+
+`test_openpbr_version.py` validates the closed-form maps, both round-trips, the
+bounded-loss edge cases, and the chaining architecture (via a synthetic 1.3
+migration) — with no rendering and no MaterialX dependency.
+
+```bash
+python -m pytest test_openpbr_version.py # or: python test_openpbr_version.py
+```
diff --git a/versioning/openpbr_version.py b/versioning/openpbr_version.py
new file mode 100644
index 0000000..0f34f62
--- /dev/null
+++ b/versioning/openpbr_version.py
@@ -0,0 +1,754 @@
+#!/usr/bin/env python
+"""
+OpenPBR Version Converter
+=========================
+
+Converts an OpenPBR Surface parameter set between specification versions
+**1.1** and **1.2**, in either direction, choosing the parameter
+correspondence that yields the *closest match of the resulting appearance*.
+
+Two layers are provided:
+
+* :func:`convert_params` -- a dependency-free core that maps a plain
+ ``{parameter_name: value}`` dict. This is the piece an application or
+ renderer embeds; it imports nothing beyond the standard library.
+* :func:`convert` -- a MaterialX (.mtlx) document wrapper that reads a shader
+ instance, converts its authored inputs, and writes the result back. This
+ layer requires the ``MaterialX`` Python package; it is otherwise
+ self-contained.
+
+What a version converter can and cannot do
+------------------------------------------
+Parameter remapping can only compensate for differences that are themselves
+*parameterized*. The 1.1 -> 1.2 changes fall into three classes:
+
+A. **Exactly invertible** reparametrizations -- appearance is identical after
+ the remap (``emission_weight``; the inert new lobes at their defaults).
+B. **Lossy / bounded** -- mappable over part of the domain, clamped + warned
+ elsewhere (``transmission_scatter`` albedo reinterpretation; the
+ ``specular_weight`` refraction decoupling).
+C. **Not parameter-correctable** -- internal BSDF / MaterialX-graph fixes that
+ no parameter controls (coat-darkening color fix #253, F82 negative-Fresnel
+ clamp #256, universal input clamping #277, graph fixes #240/#251/#250/#292/
+ #293). These are reported as *notes*; the converter makes no parameter
+ change for them.
+
+The precise per-parameter logic is documented inline in ``_rules_*`` below.
+
+Usage (CLI)
+-----------
+ python openpbr_version.py material.mtlx --from 1.1 --to 1.2 [-o out.mtlx]
+ python openpbr_version.py material.mtlx --from 1.2 --to 1.1
+
+Usage (API)
+-----------
+ from openpbr_version import convert_params, convert
+
+ res = convert_params({"emission_luminance": 500.0}, from_version="1.1", to_version="1.2")
+ res.params_out # {'emission_luminance': 500.0, 'emission_weight': 1.0}
+
+ res = convert(input_path="glass.mtlx", from_version="1.2", to_version="1.1")
+ res.output_xml # converted MaterialX XML
+"""
+
+import argparse
+import math
+import os
+import sys
+from dataclasses import dataclass, field
+from typing import Optional
+
+# ---------------------------------------------------------------------------
+# Versions
+# ---------------------------------------------------------------------------
+
+# The ordered version ladder. Conversions chain through adjacent steps, so a
+# new release only needs to (1) be appended here, (2) gain a DEFAULTS entry, and
+# (3) register one Migration with its adjacent neighbour (see MIGRATIONS below).
+# A multi-hop conversion (e.g. 1.1 -> 1.3) is composed automatically from the
+# adjacent steps. See VERSIONING.md for the full "adding a version" checklist.
+VERSION_ORDER = ("1.1", "1.2")
+
+# Backwards-compatible alias; kept as the public "is this a known version" set.
+SUPPORTED_VERSIONS = VERSION_ORDER
+
+# Tolerance for "is this value at its default" comparisons.
+_EPS = 1e-9
+# Floor for transmission_color before taking a logarithm.
+_T_FLOOR = 1e-6
+
+# MaterialX input types for parameters the converter may add/remove.
+PARAM_TYPES = {
+ "emission_weight": "float",
+ "emission_luminance": "float",
+ "emission_color": "color3",
+ "transmission_scatter": "color3",
+ "transmission_color": "color3",
+ "transmission_depth": "float",
+ "transmission_weight": "float",
+ "specular_weight": "float",
+ "specular_haze": "float",
+ "specular_haze_spread": "float",
+ "specular_retroreflectivity": "float",
+ "coat_weight": "float",
+ "coat_color": "color3",
+}
+
+# Defaults needed by the maps when a value is unauthored, per version.
+DEFAULTS = {
+ "1.1": {
+ "emission_luminance": 0.0,
+ "emission_color": (1.0, 1.0, 1.0),
+ "transmission_scatter": (0.0, 0.0, 0.0),
+ "transmission_color": (1.0, 1.0, 1.0),
+ "transmission_depth": 0.0,
+ "transmission_weight": 0.0,
+ "specular_weight": 1.0,
+ "coat_weight": 0.0,
+ "coat_color": (1.0, 1.0, 1.0),
+ },
+ "1.2": {
+ "emission_weight": 0.0,
+ "emission_luminance": 1000.0,
+ "emission_color": (1.0, 1.0, 1.0),
+ "transmission_scatter": (0.0, 0.0, 0.0),
+ "transmission_color": (1.0, 1.0, 1.0),
+ "transmission_depth": 0.0,
+ "transmission_weight": 0.0,
+ "specular_weight": 1.0,
+ "specular_haze": 0.0,
+ "specular_haze_spread": 0.3,
+ "specular_retroreflectivity": 0.0,
+ "coat_weight": 0.0,
+ "coat_color": (1.0, 1.0, 1.0),
+ },
+}
+
+
+# ---------------------------------------------------------------------------
+# Result type
+# ---------------------------------------------------------------------------
+
+@dataclass
+class ConversionResult:
+ """Result of a version conversion (mirrors translate_mtlx.TranslationResult)."""
+
+ success: bool = False
+ error: Optional[str] = None
+
+ from_version: Optional[str] = None
+ to_version: Optional[str] = None
+ material_name: Optional[str] = None
+
+ params_in: dict = field(default_factory=dict)
+ """The authored input parameters that were converted."""
+
+ params_out: dict = field(default_factory=dict)
+ """The converted parameter set."""
+
+ warnings: list = field(default_factory=list)
+ """Class-B degradation: where the closest match is not exact."""
+
+ notes: list = field(default_factory=list)
+ """Class-C information: appearance differences no parameter remap can fix."""
+
+ output_xml: Optional[str] = None
+ output_path: Optional[str] = None
+
+
+# ---------------------------------------------------------------------------
+# Value helpers (pure)
+# ---------------------------------------------------------------------------
+
+def _as3(v):
+ """Coerce a scalar or 3-sequence to a 3-tuple of floats."""
+ if isinstance(v, (int, float)):
+ f = float(v)
+ return (f, f, f)
+ seq = tuple(float(x) for x in v)
+ if len(seq) == 1:
+ return (seq[0], seq[0], seq[0])
+ if len(seq) != 3:
+ raise ValueError(f"expected a scalar or 3 components, got {v!r}")
+ return seq
+
+
+def _as_float(v):
+ if isinstance(v, (int, float)):
+ return float(v)
+ return float(v)
+
+
+def _get(params, defaults, name):
+ """Authored value if present, else the version default."""
+ if name in params:
+ return params[name]
+ return defaults[name]
+
+
+def _is_default(value, default, is_color):
+ try:
+ if is_color:
+ a, b = _as3(value), _as3(default)
+ else:
+ a, b = (_as_float(value),), (_as_float(default),)
+ except (TypeError, ValueError):
+ return False
+ return all(abs(x - y) <= _EPS for x, y in zip(a, b))
+
+
+# ---------------------------------------------------------------------------
+# transmission_scatter reparametrization (#286)
+# ---------------------------------------------------------------------------
+# Per channel, both versions share mu_t = -ln(T)/lambda (T = transmission_color,
+# lambda = transmission_depth). The scatter parameter sets the scattering
+# coefficient mu_s differently:
+# 1.1: mu_s = S / lambda (S is a coefficient, "x 1/depth")
+# 1.2: mu_s = Omega * mu_t = -Omega ln(T)/lambda (Omega is the albedo)
+# Matching mu_s (lambda cancels -- the map depends only on transmission_color):
+# 1.2 -> 1.1: S = -Omega * ln(T) (always >= 0; may exceed 1)
+# 1.1 -> 1.2: Omega = -S / ln(T) (clamped to [0,1])
+
+def _scatter_1_1_to_1_2(S, T):
+ """1.1 scattering coefficient S -> 1.2 single-scattering albedo Omega."""
+ omega, warnings = [], []
+ for c, (s, t) in enumerate(zip(_as3(S), _as3(T))):
+ if t >= 1.0 - _EPS: # mu_t = 0: no extinction
+ if s > _EPS:
+ warnings.append(
+ f"transmission_scatter[{c}]: transmission_color is 1 (no "
+ f"extinction), so the 1.1 purely-scattering medium has no "
+ f"1.2 single-scattering-albedo equivalent; set albedo 0.")
+ omega.append(0.0)
+ continue
+ lt = -math.log(max(t, _T_FLOOR)) # = -ln(T) > 0
+ o = s / lt
+ if o > 1.0 + _EPS:
+ warnings.append(
+ f"transmission_scatter[{c}] = {s:g} exceeds the 1.1 single-"
+ f"scattering regime (S > -ln T); 1.1 grey-shifts absorption "
+ f"here, which 1.2 cannot represent. Clamped albedo to 1.")
+ o = 1.0
+ omega.append(min(max(o, 0.0), 1.0))
+ return tuple(omega), warnings
+
+
+def _scatter_1_2_to_1_1(Omega, T):
+ """1.2 single-scattering albedo Omega -> 1.1 scattering coefficient S."""
+ S, warnings = [], []
+ for c, (o, t) in enumerate(zip(_as3(Omega), _as3(T))):
+ if t >= 1.0 - _EPS: # mu_t = 0: mu_s = 0 in 1.2
+ S.append(0.0)
+ continue
+ lt = -math.log(max(t, _T_FLOOR))
+ s = o * lt # exact; >= 0
+ if s > 1.0 + _EPS:
+ warnings.append(
+ f"transmission_scatter[{c}] = {s:g} exceeds the documented "
+ f"[0,1] range. The value reproduces 1.2 exactly, but predates "
+ f"1.1's input clamping; renderers that clamp will scatter less.")
+ S.append(s)
+ return tuple(S), warnings
+
+
+# ---------------------------------------------------------------------------
+# Conversion rules
+# ---------------------------------------------------------------------------
+
+def _transmission_active(params, defaults):
+ tw = _as_float(_get(params, defaults, "transmission_weight"))
+ td = _as_float(_get(params, defaults, "transmission_depth"))
+ return tw > _EPS and td > _EPS
+
+
+def _coat_notes(params, defaults, notes):
+ """Class-C: coat-darkening color fix #253 only bites with a colored coat."""
+ cw = _as_float(_get(params, defaults, "coat_weight"))
+ cc = _as3(_get(params, defaults, "coat_color"))
+ if cw > _EPS and any(abs(x - 1.0) > _EPS for x in cc):
+ notes.append(
+ "coat is active with a non-white coat_color: the coat-darkening "
+ "chromaticity fix (#253) changes the look between 1.1 and 1.2; no "
+ "parameter remap can reconcile this (Class C).")
+
+
+def _upgrade_1_1_to_1_2(params, out, warnings, notes):
+ d11 = DEFAULTS["1.1"]
+
+ # --- emission_weight (#231) + emission_luminance default 0 -> 1000 --------
+ # 1.2 emission = emission_weight * emission_color * emission_luminance.
+ # 1.2's emission_weight defaults to 0, so to preserve a 1.1 material's
+ # emission we MUST author emission_weight = 1 whenever luminance is set.
+ if "emission_weight" in params:
+ warnings.append(
+ "emission_weight was present on a 1.1 input (it does not exist in "
+ "1.1) and has been ignored.")
+ out.pop("emission_weight", None)
+ if "emission_luminance" in params:
+ out["emission_weight"] = 1.0 # luminance carried through unchanged
+
+ # --- transmission_scatter: coefficient -> albedo (#286) ------------------
+ if "transmission_scatter" in params:
+ T = _get(params, d11, "transmission_color")
+ omega, w = _scatter_1_1_to_1_2(params["transmission_scatter"], T)
+ out["transmission_scatter"] = omega
+ if _transmission_active(params, d11):
+ warnings.extend(w)
+
+ # --- specular_weight refraction decoupling (#247) ------------------------
+ # Value passes through unchanged (it matches the reflection highlight, the
+ # dominant effect). Only the refraction *direction* differs, and only when
+ # specular_weight != 1 and transmission is active.
+ _specular_weight_note(params, d11, warnings)
+
+ # New 1.2 lobes (specular_haze/_spread, specular_retroreflectivity) do not
+ # exist in 1.1 input; they take their inert 1.2 defaults -> nothing to do.
+
+ _coat_notes(params, d11, notes)
+
+
+def _downgrade_1_2_to_1_1(params, out, warnings, notes):
+ d12 = DEFAULTS["1.2"]
+
+ # --- emission: fold weight into luminance --------------------------------
+ if "emission_weight" in params or "emission_luminance" in params:
+ w = _as_float(_get(params, d12, "emission_weight"))
+ lum = _as_float(_get(params, d12, "emission_luminance"))
+ out["emission_luminance"] = w * lum
+ out.pop("emission_weight", None)
+ # emission_color HDR range [0,inf) -> [0,1] in 1.1
+ if "emission_color" in params:
+ ec = _as3(params["emission_color"])
+ if any(x > 1.0 + _EPS for x in ec):
+ warnings.append(
+ "emission_color has component(s) > 1 (HDR), outside 1.1's "
+ "[0,1] range; renderers that clamp inputs will differ.")
+
+ # --- transmission_scatter: albedo -> coefficient (#286) ------------------
+ if "transmission_scatter" in params:
+ T = _get(params, d12, "transmission_color")
+ S, w = _scatter_1_2_to_1_1(params["transmission_scatter"], T)
+ out["transmission_scatter"] = S
+ if _transmission_active(params, d12):
+ warnings.extend(w)
+
+ # --- specular_weight refraction decoupling (#247) ------------------------
+ _specular_weight_note(params, d12, warnings)
+
+ # --- new 1.2 lobes that have no 1.1 equivalent: drop, warn if non-default -
+ for name, label in (("specular_haze", "specular haze lobe"),
+ ("specular_retroreflectivity", "retroreflective lobe")):
+ if name in params:
+ if not _is_default(params[name], DEFAULTS["1.2"][name], is_color=False):
+ warnings.append(
+ f"{name} = {params[name]} has no 1.1 equivalent ({label} "
+ f"is new in 1.2); dropped. This look cannot be reproduced "
+ f"in 1.1.")
+ out.pop(name, None)
+ # specular_haze_spread is only meaningful alongside specular_haze; drop it
+ # silently (it has no effect once the haze weight is gone).
+ out.pop("specular_haze_spread", None)
+
+ _coat_notes(params, d12, notes)
+
+
+def _specular_weight_note(params, defaults, warnings):
+ sw = _as_float(_get(params, defaults, "specular_weight"))
+ if abs(sw - 1.0) > _EPS and _transmission_active(params, defaults):
+ warnings.append(
+ f"specular_weight = {sw:g} with active transmission: 1.1 and 1.2 "
+ f"compute the refraction direction from different IORs (#247). The "
+ f"reflection highlight matches, but the refraction direction will "
+ f"not match exactly (Class B; value passed through unchanged).")
+
+
+# ---------------------------------------------------------------------------
+# Migration registry
+# ---------------------------------------------------------------------------
+# Each Migration describes the conversion between one *adjacent* pair of
+# versions, in both directions. A conversion across several versions is built
+# by composing the adjacent steps (see _build_chain / convert_params).
+#
+# Each direction is a callable ``rule(params, out, warnings, notes)`` that reads
+# the source ``params`` dict and mutates ``out`` (a shallow copy of ``params``),
+# appending any degradation ``warnings`` (Class B) and ``notes`` (Class C).
+
+@dataclass
+class Migration:
+ older: str
+ newer: str
+ upgrade: object # rule(params, out, warnings, notes): older -> newer
+ downgrade: object # rule(params, out, warnings, notes): newer -> older
+
+
+MIGRATIONS = [
+ Migration("1.1", "1.2", _upgrade_1_1_to_1_2, _downgrade_1_2_to_1_1),
+ # When 1.3 lands, append: ("1.2", "1.3") to VERSION_ORDER, a DEFAULTS["1.3"]
+ # entry, and: Migration("1.2", "1.3", _upgrade_1_2_to_1_3, _downgrade_1_3_to_1_2)
+]
+
+
+def _build_chain(from_version, to_version):
+ """Ordered list of ``(rule, label)`` adjacent steps from *from* to *to*.
+
+ Returns ``None`` if any adjacent step on the path is missing. Versions are
+ linearly ordered, so the path is an unambiguous walk up or down the ladder.
+ """
+ index = {(m.older, m.newer): m for m in MIGRATIONS}
+ i_from, i_to = VERSION_ORDER.index(from_version), VERSION_ORDER.index(to_version)
+ steps = []
+ if i_to > i_from: # upgrade: walk up
+ for i in range(i_from, i_to):
+ older, newer = VERSION_ORDER[i], VERSION_ORDER[i + 1]
+ m = index.get((older, newer))
+ if m is None or m.upgrade is None:
+ return None
+ steps.append((m.upgrade, f"{older}→{newer}"))
+ else: # downgrade: walk down
+ for i in range(i_from, i_to, -1):
+ older, newer = VERSION_ORDER[i - 1], VERSION_ORDER[i]
+ m = index.get((older, newer))
+ if m is None or m.downgrade is None:
+ return None
+ steps.append((m.downgrade, f"{newer}→{older}"))
+ return steps
+
+
+# ---------------------------------------------------------------------------
+# Public core API (dependency-free)
+# ---------------------------------------------------------------------------
+
+def convert_params(params, *, from_version, to_version):
+ """Convert a plain ``{name: value}`` OpenPBR parameter dict between versions.
+
+ *params* values may be Python numbers (floats) or 3-sequences (for
+ ``color3`` parameters). Only authored parameters need be supplied;
+ unauthored ones are assumed to be at their *from_version* default.
+
+ Conversions across non-adjacent versions (e.g. ``"1.1"`` -> ``"1.3"``) are
+ composed automatically from the adjacent :data:`MIGRATIONS` steps; the
+ intermediate parameter set produced by each step is fed to the next. When
+ more than one step runs, each warning/note is prefixed with its hop label.
+
+ Returns a :class:`ConversionResult`. Parameters not affected by the
+ version change are copied through unchanged. ``result.params_out`` holds
+ the full converted set.
+ """
+ result = ConversionResult(from_version=from_version, to_version=to_version)
+
+ if from_version not in VERSION_ORDER or to_version not in VERSION_ORDER:
+ result.error = (f"Unsupported version pair {from_version!r} -> "
+ f"{to_version!r}. Supported: {VERSION_ORDER}.")
+ return result
+
+ result.params_in = dict(params)
+
+ if from_version == to_version:
+ result.params_out = dict(params)
+ result.success = True
+ return result
+
+ chain = _build_chain(from_version, to_version)
+ if chain is None:
+ result.error = (f"No conversion path defined for {from_version} -> "
+ f"{to_version} (a migration step on the ladder is "
+ f"missing).")
+ return result
+
+ multi_hop = len(chain) > 1
+ current = dict(params)
+ for rule, label in chain:
+ out = dict(current) # passthrough by default
+ step_warnings, step_notes = [], []
+ rule(current, out, step_warnings, step_notes)
+ prefix = f"[{label}] " if multi_hop else ""
+ result.warnings.extend(prefix + w for w in step_warnings)
+ result.notes.extend(prefix + n for n in step_notes)
+ current = out
+
+ result.params_out = current
+ result.success = True
+ return result
+
+
+# ---------------------------------------------------------------------------
+# MaterialX wrapper (requires the MaterialX package)
+# ---------------------------------------------------------------------------
+
+_OPENPBR_CATEGORY = "open_pbr_surface"
+
+
+def _require_materialx():
+ try:
+ import MaterialX as mx # noqa: F401
+ except ImportError as e:
+ raise RuntimeError(
+ "The MaterialX Python package is required for document conversion "
+ "(pip install MaterialX). The convert_params() core has no such "
+ "dependency.") from e
+ return mx
+
+
+def _mx_write_options(mx):
+ """XmlWriteOptions that exclude imported library definitions."""
+ opts = mx.XmlWriteOptions()
+ opts.writeXIncludeEnable = False
+ opts.elementPredicate = lambda elem: not elem.hasSourceUri()
+ return opts
+
+
+def _mx_load_document(mx, input_path=None, input_string=None):
+ """Load a MaterialX document with the standard libraries imported."""
+ doc = mx.createDocument()
+ stdlib = mx.createDocument()
+ mx.loadLibraries(mx.getDefaultDataLibraryFolders(),
+ mx.getDefaultDataSearchPath(), stdlib)
+ if input_path:
+ mx.readFromXmlFile(doc, input_path)
+ else:
+ mx.readFromXmlString(doc, input_string)
+ doc.importLibrary(stdlib)
+ return doc
+
+
+def _parse_mtlx_value(value_string, mtype):
+ """Parse a MaterialX input value string into a float or 3-tuple."""
+ if mtype in ("color3", "vector3"):
+ return tuple(float(x) for x in value_string.replace(",", " ").split())
+ return float(value_string)
+
+
+def _format_mtlx_value(value, mtype):
+ if mtype in ("color3", "vector3"):
+ return ", ".join(f"{x:g}" for x in _as3(value))
+ return f"{_as_float(value):g}"
+
+
+def _input_is_connected(inp):
+ """True if a MaterialX input draws from a node/graph rather than a literal.
+
+ Connectivity is read from the document — the caller never specifies it. An
+ input is "valued" when it has a ``value`` attribute and "connected" when it
+ instead references a ``nodename`` / ``nodegraph`` / ``output`` /
+ ``interfacename``; in the latter case ``getValueString()`` is empty.
+ """
+ return bool(inp.getNodeName() or inp.getNodeGraphString()
+ or inp.getInterfaceName() or inp.getOutputString())
+
+
+def _emission_weight_introduced(from_version, to_version):
+ """True if the conversion is an upgrade that first introduces emission_weight."""
+ fi, ti = VERSION_ORDER.index(from_version), VERSION_ORDER.index(to_version)
+ return (ti > fi
+ and "emission_weight" in DEFAULTS.get(to_version, {})
+ and "emission_weight" not in DEFAULTS.get(from_version, {}))
+
+
+def _fixup_connected_inputs(shader, from_version, to_version, warnings):
+ """Document-level fixups for *connected* inputs the value-only core can't see.
+
+ The core (``convert_params``) only sees literal values, so a node-connected
+ input is invisible to it. Most connected inputs need no action (they pass
+ through untouched), but the emission reparametrization is an exception:
+
+ Upgrading across the boundary that introduces ``emission_weight`` (1.1 ->
+ 1.2), a node-connected ``emission_luminance`` must still get
+ ``emission_weight = 1``. The new weight defaults to 0, so a textured
+ emission would otherwise go dark. ``weight = 1`` is a constant and exact
+ regardless of how luminance is driven, since 1.2 emission is
+ ``emission_weight * emission_color * emission_luminance``. (The valued case
+ is already handled by the core.)
+
+ Other connection-sensitive maps (e.g. the ``transmission_scatter`` albedo
+ remap when ``transmission_color`` is textured) cannot be reduced to a
+ constant and are left to the integrating application; see VERSIONING.md.
+ """
+ if _emission_weight_introduced(from_version, to_version):
+ lum = shader.getInput("emission_luminance")
+ if (lum is not None and _input_is_connected(lum)
+ and shader.getInput("emission_weight") is None):
+ shader.addInput("emission_weight", "float").setValueString("1")
+ warnings.append(
+ "emission_luminance is node-connected: authored emission_weight "
+ "= 1 so the textured emission is preserved (it would otherwise "
+ "default to 0 in the target version).")
+
+
+def convert(*, input_path=None, input_string=None, from_version, to_version,
+ output_path=None):
+ """Convert an OpenPBR Surface MaterialX document between versions.
+
+ Provide **either** *input_path* (a ``.mtlx`` file) or *input_string* (raw
+ MaterialX XML). Returns a :class:`ConversionResult` with ``output_xml``
+ populated (and ``output_path`` if written to disk).
+ """
+ result = ConversionResult(from_version=from_version, to_version=to_version)
+
+ if input_path and input_string:
+ raise ValueError("Provide either input_path or input_string, not both.")
+ if not input_path and not input_string:
+ raise ValueError("Provide input_path or input_string.")
+ if from_version not in SUPPORTED_VERSIONS or to_version not in SUPPORTED_VERSIONS:
+ raise ValueError(f"Supported versions are {SUPPORTED_VERSIONS}.")
+
+ mx = _require_materialx()
+
+ try:
+ doc = _mx_load_document(mx, input_path=input_path, input_string=input_string)
+ except Exception as e:
+ result.error = f"Failed to load document: {e}"
+ return result
+
+ # Find the OpenPBR shader node.
+ shader = None
+ for node in doc.getNodes():
+ if node.getCategory() == _OPENPBR_CATEGORY and node.getType() == "surfaceshader":
+ shader = node
+ break
+ if shader is None:
+ result.error = ("No open_pbr_surface shader instance found in the "
+ "document; nothing to convert.")
+ return result
+ result.material_name = shader.getName()
+
+ # Read authored inputs into a plain dict.
+ params = {}
+ for inp in shader.getInputs():
+ name = inp.getName()
+ vs = inp.getValueString()
+ if not vs:
+ continue
+ mtype = inp.getType()
+ try:
+ params[name] = _parse_mtlx_value(vs, mtype)
+ except (TypeError, ValueError):
+ params[name] = vs # leave non-numeric (e.g. connected) values alone
+
+ core = convert_params(params, from_version=from_version, to_version=to_version)
+ if not core.success:
+ result.error = core.error
+ return result
+
+ result.params_in = core.params_in
+ result.params_out = core.params_out
+ result.warnings = core.warnings
+ result.notes = core.notes
+
+ # Apply the converted parameters back onto the shader node.
+ converted_names = set(core.params_out) | set(core.params_in)
+ for name in converted_names:
+ in_out = name in core.params_out
+ if not in_out:
+ # Parameter was dropped by the conversion.
+ if shader.getInput(name) is not None:
+ shader.removeInput(name)
+ continue
+ value = core.params_out[name]
+ # Only write parameters the converter actually touched (changed/added);
+ # leave everything else exactly as authored.
+ if name in core.params_in and core.params_in[name] == value:
+ continue
+ mtype = PARAM_TYPES.get(name) or (
+ shader.getInput(name).getType() if shader.getInput(name) else "float")
+ inp = shader.getInput(name) or shader.addInput(name, mtype)
+ inp.setValueString(_format_mtlx_value(value, mtype))
+
+ # Handle connected inputs the value-only core could not see.
+ _fixup_connected_inputs(shader, from_version, to_version, result.warnings)
+
+ try:
+ result.output_xml = mx.writeToXmlString(doc, _mx_write_options(mx))
+ except Exception as e:
+ result.error = f"Conversion succeeded but failed to serialize: {e}"
+ return result
+ result.success = True
+
+ should_write = output_path is not None or input_path is not None
+ if should_write:
+ if output_path is None:
+ base, ext = os.path.splitext(input_path)
+ output_path = f"{base}_v{to_version}{ext}"
+ try:
+ mx.writeToXmlFile(doc, output_path, _mx_write_options(mx))
+ result.output_path = output_path
+ except Exception as e:
+ result.error = f"Conversion succeeded but failed to write: {e}"
+ result.success = False
+ return result
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def _print_result(result):
+ if not result.success:
+ print(f"Error: {result.error}")
+ return
+ print()
+ print(f" OpenPBR version conversion: {result.from_version} -> {result.to_version}")
+ if result.material_name:
+ print(f" Material: {result.material_name}")
+ print()
+ changed = {k: v for k, v in result.params_out.items()
+ if k not in result.params_in or result.params_in[k] != v}
+ dropped = [k for k in result.params_in if k not in result.params_out]
+ if changed:
+ print(" Changed / added parameters:")
+ for k, v in sorted(changed.items()):
+ print(f" {k:<32s} = {v}")
+ print()
+ if dropped:
+ print(" Dropped parameters:")
+ for k in sorted(dropped):
+ print(f" {k}")
+ print()
+ if result.warnings:
+ print(" Warnings (closest match is not exact):")
+ for w in result.warnings:
+ print(f" - {w}")
+ print()
+ if result.notes:
+ print(" Notes (appearance differences no remap can fix):")
+ for n in result.notes:
+ print(f" - {n}")
+ print()
+ if result.output_path:
+ print(f" Output: {result.output_path}")
+ print()
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Convert an OpenPBR Surface material between spec versions "
+ "1.1 and 1.2 (closest appearance match).",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__,
+ )
+ parser.add_argument("input", help="Input .mtlx file")
+ parser.add_argument("--from", dest="from_version", required=True,
+ choices=list(SUPPORTED_VERSIONS),
+ help="Source OpenPBR version")
+ parser.add_argument("--to", dest="to_version", required=True,
+ choices=list(SUPPORTED_VERSIONS),
+ help="Target OpenPBR version")
+ parser.add_argument("--output", "-o", help="Output .mtlx path")
+ args = parser.parse_args()
+
+ if not os.path.isfile(args.input):
+ print(f"Error: File not found: {args.input}")
+ sys.exit(1)
+
+ try:
+ result = convert(input_path=args.input, from_version=args.from_version,
+ to_version=args.to_version, output_path=args.output)
+ except (ValueError, RuntimeError) as e:
+ print(f"Error: {e}")
+ sys.exit(1)
+
+ _print_result(result)
+ sys.exit(0 if result.success else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/versioning/test_openpbr_version.py b/versioning/test_openpbr_version.py
new file mode 100644
index 0000000..85f0944
--- /dev/null
+++ b/versioning/test_openpbr_version.py
@@ -0,0 +1,441 @@
+#!/usr/bin/env python
+"""
+Numeric tests for openpbr_version.convert_params.
+
+Validates the closed-form parameter maps between OpenPBR 1.1 and 1.2, their
+round-trip identity over the exactly-invertible domain, and the bounded-loss
+edge cases (clamping + warnings). No rendering and no MaterialX dependency.
+
+Run: python -m pytest test_openpbr_version.py
+ or: python test_openpbr_version.py (built-in fallback runner)
+"""
+
+import math
+
+from openpbr_version import convert_params, _as3
+
+TOL = 1e-9
+
+
+def approx(a, b, tol=TOL):
+ if isinstance(a, (int, float)):
+ return abs(a - b) <= tol
+ return all(abs(x - y) <= tol for x, y in zip(_as3(a), _as3(b)))
+
+
+# ---------------------------------------------------------------------------
+# emission_weight (#231) + emission_luminance default change
+# ---------------------------------------------------------------------------
+
+def test_emission_1_1_to_1_2_sets_weight_one():
+ r = convert_params({"emission_luminance": 500.0}, from_version="1.1", to_version="1.2")
+ assert r.success
+ assert approx(r.params_out["emission_weight"], 1.0)
+ assert approx(r.params_out["emission_luminance"], 500.0)
+
+
+def test_emission_1_2_to_1_1_folds_weight_into_luminance():
+ r = convert_params({"emission_weight": 0.5, "emission_luminance": 1000.0},
+ from_version="1.2", to_version="1.1")
+ assert r.success
+ assert approx(r.params_out["emission_luminance"], 500.0)
+ assert "emission_weight" not in r.params_out
+
+
+def test_emission_1_2_to_1_1_uses_defaults_when_unauthored():
+ # emission_weight authored, luminance defaults to 1000 in 1.2.
+ r = convert_params({"emission_weight": 0.25}, from_version="1.2", to_version="1.1")
+ assert approx(r.params_out["emission_luminance"], 250.0)
+
+
+def test_emission_round_trip_1_1():
+ start = {"emission_luminance": 750.0}
+ up = convert_params(start, from_version="1.1", to_version="1.2")
+ down = convert_params(up.params_out, from_version="1.2", to_version="1.1")
+ assert approx(down.params_out["emission_luminance"], 750.0)
+ assert "emission_weight" not in down.params_out
+
+
+def test_emission_weight_on_1_1_input_warns():
+ r = convert_params({"emission_weight": 0.5, "emission_luminance": 100.0},
+ from_version="1.1", to_version="1.2")
+ assert any("emission_weight was present" in w for w in r.warnings)
+ assert approx(r.params_out["emission_weight"], 1.0)
+
+
+def test_emission_color_hdr_warns_going_down():
+ r = convert_params({"emission_color": (2.0, 1.0, 0.5), "emission_weight": 1.0,
+ "emission_luminance": 10.0}, from_version="1.2", to_version="1.1")
+ assert any("emission_color" in w and "HDR" in w for w in r.warnings)
+
+
+# ---------------------------------------------------------------------------
+# transmission_scatter reparametrization (#286)
+# ---------------------------------------------------------------------------
+
+def test_scatter_1_2_to_1_1_closed_form():
+ # T = 0.5 grey, Omega = 0.6 -> S = -Omega ln T = 0.6 * ln 2
+ T = (0.5, 0.5, 0.5)
+ r = convert_params({"transmission_scatter": (0.6, 0.6, 0.6),
+ "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.1},
+ from_version="1.2", to_version="1.1")
+ expected = 0.6 * (-math.log(0.5))
+ assert approx(r.params_out["transmission_scatter"], expected)
+
+
+def test_scatter_1_1_to_1_2_closed_form():
+ # Inverse of the above: S = 0.6 ln 2 with T=0.5 -> Omega = 0.6
+ T = (0.5, 0.5, 0.5)
+ S = 0.6 * (-math.log(0.5))
+ r = convert_params({"transmission_scatter": (S, S, S),
+ "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.1},
+ from_version="1.1", to_version="1.2")
+ assert approx(r.params_out["transmission_scatter"], 0.6)
+
+
+def test_scatter_round_trip_in_range():
+ # Omega in [0,1] with a transmission_color giving -ln T < 1 round-trips exactly.
+ T = (0.7, 0.5, 0.9)
+ start = {"transmission_scatter": (0.3, 0.8, 0.2), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.2}
+ down = convert_params(start, from_version="1.2", to_version="1.1")
+ up = convert_params({**down.params_out, "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.2},
+ from_version="1.1", to_version="1.2")
+ assert approx(up.params_out["transmission_scatter"], (0.3, 0.8, 0.2))
+
+
+def test_scatter_independent_of_depth():
+ T = (0.5, 0.5, 0.5)
+ a = convert_params({"transmission_scatter": (0.6, 0.6, 0.6), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.05},
+ from_version="1.2", to_version="1.1")
+ b = convert_params({"transmission_scatter": (0.6, 0.6, 0.6), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 5.0},
+ from_version="1.2", to_version="1.1")
+ assert approx(a.params_out["transmission_scatter"],
+ b.params_out["transmission_scatter"])
+
+
+def test_scatter_dark_color_exceeds_range_warns_but_exact():
+ # T = 0.1 -> -ln T = 2.30 > 1, so S = Omega * 2.30 > 1 for Omega near 1.
+ T = (0.1, 0.1, 0.1)
+ r = convert_params({"transmission_scatter": (1.0, 1.0, 1.0), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.1},
+ from_version="1.2", to_version="1.1")
+ assert r.params_out["transmission_scatter"][0] > 1.0
+ assert any("[0,1] range" in w for w in r.warnings)
+
+
+def test_scatter_1_1_grey_shift_regime_clamps_and_warns():
+ # S=1, T=0.9 -> -ln T = 0.105, so S > -ln T : 1.1 grey-shifts, Omega clamps to 1.
+ T = (0.9, 0.9, 0.9)
+ r = convert_params({"transmission_scatter": (1.0, 1.0, 1.0), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.1},
+ from_version="1.1", to_version="1.2")
+ assert approx(r.params_out["transmission_scatter"], 1.0)
+ assert any("grey-shifts" in w for w in r.warnings)
+
+
+def test_scatter_white_color_no_extinction():
+ T = (1.0, 1.0, 1.0)
+ r = convert_params({"transmission_scatter": (0.5, 0.5, 0.5), "transmission_color": T,
+ "transmission_weight": 1.0, "transmission_depth": 0.1},
+ from_version="1.1", to_version="1.2")
+ assert approx(r.params_out["transmission_scatter"], 0.0)
+ assert any("no extinction" in w for w in r.warnings)
+
+
+def test_scatter_warnings_suppressed_when_transmission_inactive():
+ # Same dark-color scatter, but transmission_weight = 0 -> medium irrelevant.
+ T = (0.1, 0.1, 0.1)
+ r = convert_params({"transmission_scatter": (1.0, 1.0, 1.0), "transmission_color": T,
+ "transmission_weight": 0.0, "transmission_depth": 0.1},
+ from_version="1.2", to_version="1.1")
+ assert r.warnings == []
+
+
+def test_scatter_default_is_noop():
+ r = convert_params({"transmission_scatter": (0.0, 0.0, 0.0)},
+ from_version="1.1", to_version="1.2")
+ assert approx(r.params_out["transmission_scatter"], 0.0)
+ assert r.warnings == []
+
+
+# ---------------------------------------------------------------------------
+# specular_weight refraction decoupling (#247)
+# ---------------------------------------------------------------------------
+
+def test_specular_weight_passthrough():
+ r = convert_params({"specular_weight": 0.5}, from_version="1.1", to_version="1.2")
+ assert approx(r.params_out["specular_weight"], 0.5)
+
+
+def test_specular_weight_warns_only_with_transmission():
+ opaque = convert_params({"specular_weight": 0.5}, from_version="1.1", to_version="1.2")
+ assert opaque.warnings == []
+ trans = convert_params({"specular_weight": 0.5, "transmission_weight": 1.0,
+ "transmission_depth": 0.1},
+ from_version="1.1", to_version="1.2")
+ assert any("refraction direction" in w for w in trans.warnings)
+
+
+def test_specular_weight_one_never_warns():
+ r = convert_params({"specular_weight": 1.0, "transmission_weight": 1.0,
+ "transmission_depth": 0.1},
+ from_version="1.2", to_version="1.1")
+ assert not any("refraction direction" in w for w in r.warnings)
+
+
+# ---------------------------------------------------------------------------
+# New 1.2 lobes dropped going down
+# ---------------------------------------------------------------------------
+
+def test_haze_non_default_warns_and_dropped():
+ r = convert_params({"specular_haze": 0.4, "specular_haze_spread": 0.5},
+ from_version="1.2", to_version="1.1")
+ assert "specular_haze" not in r.params_out
+ assert "specular_haze_spread" not in r.params_out
+ assert any("specular_haze" in w for w in r.warnings)
+
+
+def test_haze_default_dropped_silently():
+ r = convert_params({"specular_haze": 0.0}, from_version="1.2", to_version="1.1")
+ assert "specular_haze" not in r.params_out
+ assert r.warnings == []
+
+
+def test_retroreflectivity_non_default_warns():
+ r = convert_params({"specular_retroreflectivity": 0.7},
+ from_version="1.2", to_version="1.1")
+ assert "specular_retroreflectivity" not in r.params_out
+ assert any("retroreflective" in w for w in r.warnings)
+
+
+def test_new_lobes_absent_going_up_is_clean():
+ r = convert_params({"base_color": (0.2, 0.4, 0.6)},
+ from_version="1.1", to_version="1.2")
+ assert r.warnings == []
+ assert approx(r.params_out["base_color"], (0.2, 0.4, 0.6))
+
+
+# ---------------------------------------------------------------------------
+# Class-C notes
+# ---------------------------------------------------------------------------
+
+def test_coat_darkening_note_when_colored_coat_active():
+ r = convert_params({"coat_weight": 1.0, "coat_color": (0.8, 0.2, 0.1)},
+ from_version="1.2", to_version="1.1")
+ assert any("coat-darkening" in n for n in r.notes)
+
+
+def test_no_coat_note_for_white_coat():
+ r = convert_params({"coat_weight": 1.0, "coat_color": (1.0, 1.0, 1.0)},
+ from_version="1.2", to_version="1.1")
+ assert r.notes == []
+
+
+# ---------------------------------------------------------------------------
+# Passthrough / plumbing
+# ---------------------------------------------------------------------------
+
+def test_unaffected_params_pass_through_unchanged():
+ src = {"base_color": (0.1, 0.6, 0.9), "specular_roughness": 0.25,
+ "coat_weight": 0.0}
+ r = convert_params(src, from_version="1.1", to_version="1.2")
+ for k, v in src.items():
+ assert approx(r.params_out[k], v)
+
+
+def test_same_version_is_identity():
+ src = {"base_color": (0.1, 0.6, 0.9), "emission_luminance": 500.0}
+ r = convert_params(src, from_version="1.2", to_version="1.2")
+ assert r.params_out == src
+ assert r.warnings == []
+
+
+def test_unsupported_version_errors():
+ r = convert_params({}, from_version="1.0", to_version="1.2")
+ assert not r.success
+ assert "Unsupported" in r.error
+
+
+def test_input_not_mutated():
+ src = {"emission_luminance": 500.0}
+ convert_params(src, from_version="1.1", to_version="1.2")
+ assert src == {"emission_luminance": 500.0}
+
+
+# ---------------------------------------------------------------------------
+# MaterialX wrapper: connected (node-driven) inputs
+# (skipped automatically if the MaterialX package is not installed)
+# ---------------------------------------------------------------------------
+
+def _materialx_available():
+ try:
+ import MaterialX # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+def _openpbr_doc(emission_luminance_input):
+ """A minimal OpenPBR doc whose emission_luminance is the given XML snippet."""
+ return f"""
+
+
+
+
+
+ {emission_luminance_input}
+
+
+
+
+
+"""
+
+
+def test_wrapper_connected_emission_luminance_gets_weight():
+ if not _materialx_available():
+ return # skip
+ from openpbr_version import convert
+ xml = _openpbr_doc('')
+ r = convert(input_string=xml, from_version="1.1", to_version="1.2")
+ assert r.success
+ assert 'name="emission_weight"' in r.output_xml # authored as a constant
+ assert 'value="1"' in r.output_xml
+ assert 'nodename="lum_tex"' in r.output_xml # connection preserved
+ assert any("node-connected" in w for w in r.warnings)
+
+
+def test_wrapper_valued_emission_luminance_gets_weight_via_core():
+ if not _materialx_available():
+ return # skip
+ from openpbr_version import convert
+ xml = _openpbr_doc('')
+ r = convert(input_string=xml, from_version="1.1", to_version="1.2")
+ assert r.success
+ assert 'name="emission_weight"' in r.output_xml
+ # The valued path is handled by the core, not the connection fixup:
+ assert not any("node-connected" in w for w in r.warnings)
+
+
+def test_wrapper_no_emission_no_weight_added():
+ if not _materialx_available():
+ return # skip
+ from openpbr_version import convert
+ xml = """
+
+
+
+
+
+
+
+"""
+ r = convert(input_string=xml, from_version="1.1", to_version="1.2")
+ assert r.success
+ assert "emission_weight" not in r.output_xml # no emission -> no weight
+
+
+def test_wrapper_connected_weight_not_added_when_downgrading():
+ if not _materialx_available():
+ return # skip
+ from openpbr_version import convert
+ xml = _openpbr_doc('')
+ r = convert(input_string=xml, from_version="1.2", to_version="1.1")
+ assert r.success
+ assert "emission_weight" not in r.output_xml # weight doesn't exist in 1.1
+
+
+# ---------------------------------------------------------------------------
+# Chaining architecture: composing adjacent migrations (e.g. 1.1 -> 1.3)
+# ---------------------------------------------------------------------------
+
+import contextlib
+
+import openpbr_version as ov
+
+
+@contextlib.contextmanager
+def _synthetic_1_3():
+ """Temporarily register a fake 1.3 migration to exercise multi-hop chaining."""
+ def up_1_2_to_1_3(params, out, warnings, notes):
+ out["lobe_1_3"] = 1.0 # a new 1.3 parameter
+ warnings.append("synthetic upgrade warning")
+ def down_1_3_to_1_2(params, out, warnings, notes):
+ out.pop("lobe_1_3", None)
+
+ saved_order, saved_migs, saved_defaults = (
+ ov.VERSION_ORDER, ov.MIGRATIONS, dict(ov.DEFAULTS))
+ ov.VERSION_ORDER = ("1.1", "1.2", "1.3")
+ ov.MIGRATIONS = saved_migs + [
+ ov.Migration("1.2", "1.3", up_1_2_to_1_3, down_1_3_to_1_2)]
+ ov.DEFAULTS["1.3"] = dict(ov.DEFAULTS["1.2"], lobe_1_3=0.0)
+ try:
+ yield
+ finally:
+ ov.VERSION_ORDER, ov.MIGRATIONS = saved_order, saved_migs
+ ov.DEFAULTS.clear()
+ ov.DEFAULTS.update(saved_defaults)
+
+
+def test_multi_hop_composes_adjacent_steps():
+ with _synthetic_1_3():
+ r = ov.convert_params({"emission_luminance": 400.0},
+ from_version="1.1", to_version="1.3")
+ assert r.success
+ # 1.1 -> 1.2 effect (emission_weight) AND 1.2 -> 1.3 effect (lobe_1_3):
+ assert approx(r.params_out["emission_weight"], 1.0)
+ assert approx(r.params_out["lobe_1_3"], 1.0)
+
+
+def test_multi_hop_prefixes_warnings_with_hop_label():
+ with _synthetic_1_3():
+ r = ov.convert_params({"specular_weight": 0.5, "transmission_weight": 1.0,
+ "transmission_depth": 0.1},
+ from_version="1.1", to_version="1.3")
+ assert any(w.startswith("[1.1→1.2]") for w in r.warnings)
+ assert any(w.startswith("[1.2→1.3]") for w in r.warnings)
+
+
+def test_multi_hop_downgrade_round_trip():
+ with _synthetic_1_3():
+ up = ov.convert_params({"emission_luminance": 400.0},
+ from_version="1.1", to_version="1.3")
+ down = ov.convert_params(up.params_out, from_version="1.3", to_version="1.1")
+ assert "lobe_1_3" not in down.params_out
+ assert approx(down.params_out["emission_luminance"], 400.0)
+
+
+def test_single_hop_warnings_are_unprefixed():
+ # Regression: single-hop output must stay clean (no "[a->b]" prefix).
+ r = convert_params({"specular_weight": 0.5, "transmission_weight": 1.0,
+ "transmission_depth": 0.1}, from_version="1.1", to_version="1.2")
+ assert r.warnings and not any(w.startswith("[") for w in r.warnings)
+
+
+# ---------------------------------------------------------------------------
+# Fallback runner (no pytest required)
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+ import sys
+ tests = [v for k, v in sorted(globals().items())
+ if k.startswith("test_") and callable(v)]
+ passed = failed = 0
+ for t in tests:
+ try:
+ t()
+ passed += 1
+ except AssertionError as e:
+ failed += 1
+ print(f"FAIL {t.__name__}: {e}")
+ except Exception as e:
+ failed += 1
+ print(f"ERROR {t.__name__}: {type(e).__name__}: {e}")
+ print(f"\n{passed} passed, {failed} failed ({len(tests)} total)")
+ sys.exit(1 if failed else 0)