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)