Skip to content

[BUG FIX] Make Surface shortcut resolution idempotent#2761

Merged
duburcqa merged 4 commits into
Genesis-Embodied-AI:mainfrom
Kashu7100:fix-surface-shortcut-idempotent
May 12, 2026
Merged

[BUG FIX] Make Surface shortcut resolution idempotent#2761
duburcqa merged 4 commits into
Genesis-Embodied-AI:mainfrom
Kashu7100:fix-surface-shortcut-idempotent

Conversation

@Kashu7100
Copy link
Copy Markdown
Collaborator

Summary

The model_validator(mode="after") resolvers on Surface (_resolve_shortcuts) and Glass (_post_init) route shortcut fields (color, opacity, roughness, metallic, emissive, thickness) into their corresponding *_texture fields and then raise if both are populated. They never clear the shortcut after applying it, so the instance ends up with both fields set.

This works when constructing a surface in isolation, but raises GenesisException: 'color' and 'diffuse_texture' cannot both be set. the moment the surface is nested inside another Pydantic model (downstream frameworks that wrap Genesis options for declarative scene configs commonly hit this). Pydantic 2 re-runs after-mode validators on nested instances even with revalidate_instances="never" — the second pass sees both color and diffuse_texture populated and trips the conflict check.

Repro

from pydantic import BaseModel
import genesis as gs
from genesis.options.surfaces import Surface

class Wrapper(BaseModel):
    surface: Surface

s = gs.surfaces.Rough(color=(0.4, 0.4, 0.4))   # OK
Wrapper(surface=s)                              # GenesisException — 'color' and 'diffuse_texture' cannot both be set.

Fix

Clear each shortcut after routing it into the texture field, so re-running the validator is a no-op. The default_roughness sync moves to the top of _resolve_shortcuts because the loop that consumes self.roughness now nulls it.

A repo-wide grep confirmed no other code reads surface.<shortcut> after construction — these fields have Field(exclude=True, repr=False) and are documented as input-only shortcut parameters.

Test plan

  • gs.surfaces.Rough(color=...), Glass(color=..., thickness=...), BSDF(color=..., roughness=..., metallic=...), Emission(color=...) round-trip safely through nested Pydantic models.
  • Asserts the resolved texture values are correct (not just non-None).
  • default_roughness sync still mirrors the roughness shortcut, and an explicit default_roughness still wins over the shortcut.
  • First-construction conflicts still raise (e.g., Rough(color=..., diffuse_texture=...), Glass(thickness=..., thickness_texture=...)).
  • Re-using an already-resolved surface in multiple wrappers does not mutate it further.

🤖 Generated with Claude Code

@Kashu7100
Copy link
Copy Markdown
Collaborator Author

tests are failing due to fetch issue - not related to this PR.

Comment thread genesis/options/surfaces.py
@duburcqa duburcqa force-pushed the fix-surface-shortcut-idempotent branch 2 times, most recently from 9e1642c to 1219b69 Compare May 12, 2026 14:12
Kashu7100 and others added 4 commits May 12, 2026 16:12
The ``model_validator(mode="after")`` resolvers on ``Surface`` and
``Glass`` route shortcut fields (``color``, ``opacity``, ``roughness``,
``metallic``, ``emissive``, ``thickness``) into their corresponding
``*_texture`` fields, then raise if both are populated. They never cleared
the shortcut after applying it, so the instance ended up with both
populated.

This worked when constructing a surface in isolation, but raised
``GenesisException: 'color' and 'diffuse_texture' cannot both be set.``
the moment the surface was nested inside another Pydantic model — e.g.
``MySurfaceWrapper(surface=gs.surfaces.Rough(color=...))`` — because
Pydantic re-runs after-mode validators on nested instances and the
second pass tripped the conflict check.

Fix: clear each shortcut after routing it into the texture field. The
``default_roughness`` sync moves to the top of ``_resolve_shortcuts``
because the loop that consumes ``self.roughness`` now nulls it.

Adds tests covering idempotent re-validation across Plastic-family,
Glass, BSDF, and Emit surfaces; ``default_roughness`` sync ordering;
and that first-construct conflict detection still raises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch from clearing the shortcut fields after resolution to a private
``_shortcuts_resolved`` flag that gates ``Surface._resolve_shortcuts``.
The previous approach (nulling ``self.color``, etc.) preserved
idempotency but broke downstream consumers that read these fields:
e.g., the Nyx scene exporter at gs_nyx_plugin populates ``mat.albedoColor``
from ``surface.color`` directly, so colored entities rendered as white
once the shortcut was nulled.

The flag preserves the shortcut fields verbatim while still gating the
"both set" conflict check so re-validation (nested in another Pydantic
model) is a no-op.

Also consolidate the Glass ``thickness`` shortcut into the same loop in
``Surface._resolve_shortcuts`` (gated by ``texture_field in
self.model_fields`` so non-Glass surfaces skip it). ``Glass._post_init``
keeps only its idempotent texture-shape normalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@duburcqa duburcqa force-pushed the fix-surface-shortcut-idempotent branch from 1219b69 to 1b8bf33 Compare May 12, 2026 14:12
@duburcqa duburcqa merged commit fe76e6b into Genesis-Embodied-AI:main May 12, 2026
18 of 20 checks passed
@github-actions
Copy link
Copy Markdown

🔴 Benchmark Regression Detected ➡️ Report

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.

2 participants