Skip to content

feat(pbr): optional threejs-materials backend via pymat.pbr Protocol (draft — refs #3)#30

Draft
gerchowl wants to merge 6 commits intodevfrom
feature/3-pbr-protocol-integration
Draft

feat(pbr): optional threejs-materials backend via pymat.pbr Protocol (draft — refs #3)#30
gerchowl wants to merge 6 commits intodevfrom
feature/3-pbr-protocol-integration

Conversation

@gerchowl
Copy link
Copy Markdown
Contributor

Draft — exploratory

Opened as a draft to give @gumyr and @bernhard-42 a concrete implementation to react to on #3. Do not merge until we have their input. The direction matches Option II from the analysis posted on #3 — optional extra, not absorption — but this is implementation only; actual integration with build123d and ocp_vscode comes after the collaboration direction is settled.

What this implements

See ADR-0002 (docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md) for the full design rationale. TL;DR:

  • `pymat.pbr.PbrSource` — `typing.Protocol` with one method: `to_three_js_dict()`. Any object conforming can be assigned to `Material.pbr_source`.
  • Native lite `PBRProperties` dataclass grows `to_three_js_dict()` → emits Three.js `MeshPhysicalMaterial` camelCase dict. Makes the native type a first-class `PbrSource`.
  • `Material.pbr_source: Optional[PbrSource] = None` field + `to_three_js_material_dict()` dispatch method. Rich backend wins when set, lite fallback otherwise.
  • `[pbr]` optional extra pins `threejs-materials>=1.0.0` (Bernhard's library). Conditional `from pymat.pbr import PbrProperties` re-export when installed.
  • 7 new tests (140/11-skipped full suite, was 133/11).
  • `examples/pbr_integration.py` — runnable demo, prints both backends' output, writes JSON to `examples/output/`, gracefully degrades without the extra. Includes `--visual` flag for manual ocp_vscode rendering.

Not in this PR

  • build123d `Shape.material` patch: needs a fork (not yet created), and Roger's design preference on how that attribute should be typed. Tracked in Further build123d/ocp_vscode integration #3.
  • Automated headless ocp_vscode snapshot: not currently feasible without a running VS Code + display. Manual visual verification is documented in the example script.
  • Actually merging this: blocked on feedback from Roger and Bernhard on the overall direction.

Side incident: typos hook auto-rewrote metalnessmetallicity

Separate small commit on the same branch: the `typos` pre-commit hook (same one that hit us with `Macor → Macro` and `Nd → And` during the bootstrap session) decided `metalness` — the actual Three.js `MeshPhysicalMaterial` API key — was a typo for `metallicity` (not a word). Silently corrupted 4 files. Hardened `.typos.toml` with `metalness` + `metalnessMap` in extend-words. Swap to a less aggressive spell-checker is worth considering as a follow-up.

Test plan

Refs

🤖 Generated with Claude Code

gerchowl and others added 3 commits April 15, 2026 15:55
- .typos.toml: add `metalness` and `metalnessMap` to extend-words.
  These are Three.js MeshPhysicalMaterial API keys; typos auto-rewrites
  them to `metallicity`/`metallicityMap` (not words) without this pin.
  Same failure class as the earlier Macor → Macro and Nd → And incidents
  from the bootstrap session.
- .gitignore: exclude `examples/output/` — runtime output dir for the
  new examples/pbr_integration.py script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements ADR-0002: add PBR integration as an optional extra so
py-materials can carry full MaterialX rendering data while physics-
only users stay lean.

Design
------

- `pymat.pbr.PbrSource` — runtime_checkable typing Protocol with
  one method, `to_three_js_dict()`. Any conforming object can be
  assigned to `Material.pbr_source`.
- Native lite `PBRProperties` dataclass gains `to_three_js_dict()`
  so it satisfies the Protocol. Outputs Three.js MeshPhysicalMaterial
  camelCase keys (color, metalness, roughness, transmission,
  opacity, transparent, emissive, ior, clearcoat, normalMap,
  roughnessMap, metalnessMap, aoMap) with defaults omitted.
- `Material` gains `pbr_source: Optional[PbrSource] = None` field
  and `to_three_js_material_dict()` method. When `pbr_source` is
  set, it takes precedence over `properties.pbr` (the lite native
  backend) for rendering output.
- `[pbr]` optional extra pins `threejs-materials>=1.0.0`
  (Bernhard's Apache-2.0 library, canonical PBR loader consumed
  by ocp_vscode). Also added to the `all` extra.
- `from pymat.pbr import PbrProperties` is a conditional re-export
  of `threejs_materials.PbrProperties` when the extra is installed,
  so users can write one import and don't need to know about the
  underlying library.

Usage
-----

Without the extra (physics-only path, default install):

    from pymat import Material
    steel = Material(
        name="Steel", density=7.85,
        pbr={"base_color": (0.75, 0.75, 0.77, 1.0), "metallic": 1.0},
    )
    steel.to_three_js_material_dict()  # → lite backend output

With the extra (`pip install py-materials[pbr]`):

    from pymat import Material
    from pymat.pbr import PbrProperties
    steel = Material(
        name="Brushed Steel", density=7.85, formula="Fe",
        pbr_source=PbrProperties.from_gpuopen("Stainless Steel Brushed"),
    )
    steel.to_three_js_material_dict()  # → rich threejs-materials output

Tests
-----

- 7 new tests in tests/test_pbr.py covering:
  - Protocol conformance (isinstance check via runtime_checkable)
  - Native lite backend serialization (minimal + full field coverage)
  - Material dispatch to lite vs rich backend (via a stub conforming
    to the Protocol — no threejs-materials install required for the
    base test suite)
  - Non-conforming object rejection
- Full suite 140 passed / 11 skipped (was 133 / 11 before).

Documentation
-------------

- docs/decisions/0002-pbr-via-threejs-materials-optional-extra.md
  captures the design rationale, six options considered, and the
  upgrade trigger (when a second PBR backend emerges, or if
  threejs-materials's maintenance changes).
- examples/pbr_integration.py — runnable end-to-end demo. Prints
  physics properties, emits Three.js JSON to stdout, writes JSON
  to examples/output/ for downstream viewer consumption. Graceful
  degrade when [pbr] not installed. Includes a --visual flag that
  opens the Material in ocp_vscode (manual path; automated headless
  ocp_vscode snapshot is tracked as a follow-up).

Refs: #3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `visual_demo()` block using build123d + ocp_vscode belongs in the
build123d fork's examples dir (which already ships `[ocp_vscode]` as
an optional extra), not in py-materials itself. py-materials' example
stays minimal: demonstrate the py-mat API with lite + rich backends
and emit Three.js JSON, no build123d dependency.

The full composition example lives at
`gerchowl/build123d@feature/pymat-material-integration:examples/pbr_material_pymat.py`,
which imports both libraries and optionally calls `ocp_vscode.show()`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gerchowl added a commit to gerchowl/build123d that referenced this pull request Apr 15, 2026
…mat#3)

Evolution of the existing `material: str` attribute on `Compound`
and `Solid` to also accept `pymat.Material` instances, per the
feature request in MorePET/mat#3 from this project's user community.

The existing str tag (used by STEP/STL exporters as an external
tool label) stays accepted — this is a **type widening**, not a
rename. Downstream code using `isinstance(shape.material, str)`
keeps working; new code can do
`isinstance(shape.material, pymat.Material)` to opt into the
richer physics + PBR path.

Changes
-------

- `Compound.__init__`: `material: str | PymatMaterial | None = None`
- `Solid.__init__`: same type widen
- Both docstrings explain the dual meaning + point at the
  `build123d[materials]` extra
- `pyproject.toml`: new `[materials]` optional extra pulling
  `py-materials[pbr]` from the MorePET/mat feature branch (git+https
  pin — to be replaced with a PyPI version pin once py-materials
  2.2.0 ships the `pbr` extra on PyPI, before proposing this PR
  upstream to gumyr/build123d)
- `all` extra now includes `[materials]`
- TYPE_CHECKING-only import of `pymat.Material as PymatMaterial` in
  both `composite.py` and `three_d.py` — build123d has **no runtime
  dependency** on py-materials unless the user installs
  `build123d[materials]`

API collision note
------------------

The existing `Compound.material` / `Solid.material` attribute is
currently used by STEP/STL exporters as a free-form str tag for
downstream tool metadata. The issue author
([MorePET/mat#3](MorePET/mat#3)) may or
may not have been aware of this collision when proposing the
`shape.material = Material(...)` API. The type widen preserves
every existing usage while enabling the new path. If a stricter
resolution is preferred (separate attribute, deprecation path),
that's a conversation for the issue — this PR takes the minimum
invasive route.

End-to-end example
------------------

`examples/pbr_material_pymat.py` demonstrates the full flow:

1. Create a build123d Part (Box minus Cylinder)
2. Create a `pymat.Material` with physics (density, formula, thermal)
   and a rich PBR backend via `threejs_materials.PbrProperties.from_gpuopen(...)`
3. `part.material = steel` — single assignment, both consumers read
   from the same object
4. Print physics (density, molar_mass), geometry-derived mass
   (volume × density), and the Three.js MeshPhysicalMaterial dict
5. `--visual` flag calls `ocp_vscode.show(part)` for live rendering

Verified end-to-end with a local py-materials install:

    === Physics properties (via py-materials) ===
      density:      8.0 g/cm³
      molar mass:   55.85 g/mol
      part volume:  21.99 cm³
      part mass:    175.92 g

    === PBR rendering (Three.js MeshPhysicalMaterial dict) ===
    { "color": [0.75, 0.75, 0.77], "metalness": 1.0, "roughness": 0.35 }

Install
-------

    pip install build123d[materials,ocp_vscode]

or for the lite path (physics + basic PBR scalars, no texture lib):

    pip install build123d py-materials
    # no [pbr] extra → uses the lite in-tree pymat PBRProperties

Draft / exploratory
-------------------

This branch and PR are exploratory — pending design direction on
MorePET/mat#3 from @gumyr and @bernhard-42. Not ready to merge
upstream until:

- py-materials' `feature/3-pbr-protocol-integration` (MorePET/mat#30)
  lands on dev and ships as py-materials 2.2.0 on PyPI
- The `materials` optional-dependency pin is switched from
  git+https to a PyPI version (`py-materials[pbr]>=2.2.0`)
- Collision + type-widening approach is reviewed by @gumyr
gerchowl and others added 2 commits April 15, 2026 16:33
…xtra

Graceful enhancement for existing downstream renderers that already
read `material.properties.pbr.<field>` directly, e.g. `ocp_vscode`'s
`_extract_materials_from_node()` in `show.py`.

## The problem

ADR-0002 added `Material.pbr_source` as the rich backend field.
`Material.to_three_js_material_dict()` dispatches to the rich source
when set. But existing ocp_vscode (and any other renderer that
reads the lite `properties.pbr` dataclass directly) can't see the
rich-backend data without a code change on their side.

## The fix

When `pbr_source` is set, `__post_init__` now calls
`_backfill_pbr_from_source()` which projects the rich backend's
`to_three_js_dict()` output onto the lite dataclass: color,
metalness, roughness, ior, emissive, transmission, clearcoat, plus
normal/roughness/metalness/ao maps.

Result: `material.pbr_source = PbrProperties.from_gpuopen(...)`
renders with MaterialX textures through existing ocp_vscode today,
**no adapter change on Bernhard's side required**. The rich source
still takes precedence in `to_three_js_material_dict()` for callers
that can handle extra fields (sheen, anisotropy, iridescence, etc.)
not present in the lite dataclass.

One-way copy at `__post_init__` only — not a live sync. Re-assigning
`pbr_source` re-runs the backfill. Fields without a lite counterpart
are dropped in the projection; the lossy subset is documented in the
updated ADR-0002.

## Also

- `[pbr]` extra now pulls `threejs-materials[materialx]>=1.0.0`
  instead of `threejs-materials>=1.0.0`. The `[materialx]` sub-extra
  brings the MaterialX SDK, without which `PbrProperties.from_gpuopen`
  errors on first load ("materialx is not installed").
- 5 new tests in `TestPbrBackfill` covering: scalar backfill, texture
  map backfill, adapter compatibility (simulates Bernhard's adapter
  reading `properties.pbr.<field>`), no-op when pbr_source unset,
  rich source still wins in dispatch.
- ADR-0002 updated with a dedicated "Backfill pattern" section.

Full suite: 145 passed / 11 skipped (was 140 / 11).

Refs: #3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
threejs_materials.PbrProperties.to_dict() returns a nested dict:

    {
      "id": "...", "name": "...", "source": "...", "url": "...",
      "license": "...",
      "values": {color: [...], metalness: ..., roughness: ..., ...},
      "textures": {normal: "...", roughness: "...", ...},
    }

not a flat Three.js MeshPhysicalMaterial dict. My first backfill
pass assumed the flat shape, which worked against stub backends in
the unit tests but failed at runtime against the real library with
an AttributeError on `to_three_js_dict`.

This change:

- Detects the shape at runtime via `isinstance(d.get("values"), dict)`
  and picks `values` for scalars, `textures` for maps when nested.
  Falls back to reading top-level keys when flat (so the native lite
  `PBRProperties.to_dict()` flat output still works).
- Uses short-form map names (`normal`, `roughness`, `metalness`, `ao`)
  that threejs-materials' PbrMaps dataclass emits, with a fallback
  to the camelCase `normalMap`/`roughnessMap`/etc. for the flat
  shape.

Verified end-to-end with a real threejs_materials.PbrProperties:

    >>> from threejs_materials import PbrProperties
    >>> rich = PbrProperties.create('Steel', color=[.91,.91,.88], metalness=1, roughness=.08)
    >>> steel = Material(name='Steel', pbr_source=rich)
    >>> steel.properties.pbr.base_color
    (0.91, 0.91, 0.88, 1.0)
    >>> steel.properties.pbr.metallic
    1.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gerchowl added a commit to gerchowl/vscode-ocp-cad-viewer that referenced this pull request Apr 15, 2026
When a `pymat.Material` carries a rich `pbr_source` (a
`threejs_materials.PbrProperties` instance, typically set via
`material.pbr_source = PbrProperties.from_gpuopen(...)`),
`_extract_materials_from_node` now bypasses the field-by-field
projection through the lite `pymat.properties.pbr` dataclass and
uses the rich source directly.

## Why

`py-materials`' lite `PBRProperties` dataclass has no
`base_color_map` / albedo / color texture field. Before this
change, the `is_pymat(node.material)` branch would call
`PbrProperties.create(..., normal_map=..., roughness_map=...,
metalness_map=..., ao_map=...)` and silently drop the color
texture. For materials whose visual identity lives in the color
map (wood, bricks, tiles, stone — most non-metal MaterialX
libraries on matlib.gpuopen.com, polyhaven.com, ambientcg.com),
this made them render as flat white meshes even with a valid
`pbr_source` assigned.

Verified locally with `gpuopen/Ivory Walnut Solid Wood`:

- `part.material = PbrProperties.from_gpuopen("Ivory Walnut Solid Wood")`
  renders with full wood grain (takes the
  `isinstance(node.material, PbrProperties)` fast path).
- `part.material = pymat.Material(..., pbr_source=<same PbrProperties>)`
  previously rendered as flat white (lossy field copy dropped the
  color map). With this change, it now renders identically to the
  direct assignment.

## Design

The fix is 1 conditional + 2 lines: prefer `pbr_source` when set,
fall back to the field-by-field copy when not. Materials without
a rich source (TOML-authored, lite-only) continue to work exactly
as before — backward compatible.

The rich source path also picks up every Three.js
MeshPhysicalMaterial scalar that py-materials' lite dataclass
doesn't model (sheen, anisotropy, iridescence, dispersion,
clearcoat maps, specular intensity/color, thickness, displacement,
specular tinting). These were previously unreachable through the
pymat path.

## Context

Part of an ongoing collaboration between `gumyr/build123d`,
`MorePET/mat` (py-materials), and `bernhard-42/threejs-materials`
to make `shape.material = pymat.Material(...)` the canonical
carrier type in build123d (both physics values and PBR rendering).

- `MorePET/mat#3` — design discussion
- `MorePET/mat#30` — py-materials PR adding `Material.pbr_source`
- `gumyr/build123d#1276` — build123d PR widening
  `Compound/Solid.material` type
- `MorePET/mat`'s ADR-0002 — architectural rationale for the
  Protocol + optional-extra approach

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lite `PBRProperties` dataclass previously had fields for
`normal_map`, `roughness_map`, `metallic_map`, and
`ambient_occlusion_map` but no `base_color_map`. This meant the
PRIMARY texture channel — the albedo/diffuse map that carries
most of the visual identity for wood, bricks, tiles, stone,
and most non-metal PBR materials — was silently dropped during
backfill from a rich `pbr_source`.

Metals accidentally survived because their visual character
comes from `metalness=1` + `roughness` + environment reflection,
not an albedo map. Non-metal materials rendered as flat white.

Changes:

- `PBRProperties.base_color_map: Optional[str] = None` (new field).
- `PBRProperties.to_dict()` emits it as `map` (Three.js's name
  for the color channel is plain `map`, not `colorMap` / `albedoMap`).
- `_backfill_pbr_from_source()` reads it from both the nested
  `{textures: {color: ...}}` shape (threejs_materials v1) and
  the flat `{map: ...}` shape (native lite output).
- New test `test_backfill_handles_nested_threejs_shape` captures
  a realistic fixture matching threejs_materials' real output
  (verified against the live library at v1.0.4).

Note: this fix alone isn't sufficient for ocp_vscode rendering —
Bernhard's `_extract_materials_from_node` reads texture fields
from `pbr.normal_map`/etc. but never from an albedo field, so
the map never makes it to `PbrProperties.create()`. The complete
fix is the companion PR on bernhard-42/vscode-ocp-cad-viewer#228
which bypasses the lossy field-copy entirely when `pbr_source` is
set. This base_color_map field still lands for completeness and
for any future downstream consumer that does read the lite
dataclass directly.

Refs #3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant