Skip to content

[BUG FIX] Make Surface shortcut resolution idempotent#2761

Open
Kashu7100 wants to merge 3 commits intoGenesis-Embodied-AI:mainfrom
Kashu7100:fix-surface-shortcut-idempotent
Open

[BUG FIX] Make Surface shortcut resolution idempotent#2761
Kashu7100 wants to merge 3 commits intoGenesis-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

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>
Kashu7100 and others added 2 commits May 4, 2026 16:15
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>
@Kashu7100
Copy link
Copy Markdown
Collaborator Author

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

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