Skip to content

Implement Figure-level overlay architecture with two-pass drawing - #32199

Open
Vikash-Kumar-23 wants to merge 3 commits into
matplotlib:mainfrom
Vikash-Kumar-23:container-managed-overlays
Open

Implement Figure-level overlay architecture with two-pass drawing#32199
Vikash-Kumar-23 wants to merge 3 commits into
matplotlib:mainfrom
Vikash-Kumar-23:container-managed-overlays

Conversation

@Vikash-Kumar-23

@Vikash-Kumar-23 Vikash-Kumar-23 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR summary

This PR introduces a foundational Figure-level overlay architecture to figure.py. It implements a two-pass drawing system, allowing developers to cleanly segregate base plot artists from overlay artists.

Key Changes:

  • Layered Artist Storage: _children_by_layer dictionary in FigureBase. Artists are now routed to distinct lists based on their layer (e.g., "base", "overlay"), and self.patch has been isolated into its own dedicated "patch" layer.
  • Multi-Pass Drawing: Modified Figure.draw() and SubFigure.draw() to execute in multiple passes using a new, generic _draw_layer(renderer, layer_name) method. The strict sequence is now:
    • "patch" layer: Renders the figure background first.
    • "base" layer: Renders all standard artists.
    • "overlay" layer: Renders all overlay artists last.
  • Public API Routing: Added a layer=None keyword-only argument add_artist and get_children

Addresses #30515

AI Disclosure

AI tools were used to assist in drafting text and suggesting validation scenarios.
All code changes, final implementation decisions, and verification were done manually.

PR quality check

  • Use an expressive title, e.g. "Fix title font property precedence"
  • New and changed code is tested
  • Plotting related features are demonstrated in an example
  • New features and API changes have release notes
  • Documentation complies with general and docstring guidelines

fig._draw_base_layer = lambda renderer: None
fig.patch.set_alpha(0.0)
ax.patch.set_alpha(0.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need three tests here:

  • base
  • overlay
  • composite

b/c if you have to knock out the patch on the overlay, that seems to indicate you're not getting clean independence.

@ksunden ksunden left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main idea for this review is to push towards making the layering system more generic. Instead of just one overlay, it is possible to extend into more, which has the added benefit of enabling us to clean up the code and reduce duplicated code.

I've laid out a series of specific changes that I think will add up to making this more useful and cleaner, outlined below.

Comment thread lib/matplotlib/figure.py
Comment on lines 251 to 259
for ax in self._localaxes:
locator = ax.get_axes_locator()
ax.apply_aspect(locator(ax, renderer) if locator else None)

for child in ax.get_children():
if hasattr(child, 'apply_aspect'):
locator = child.get_axes_locator()
child.apply_aspect(
locator(child, renderer) if locator else None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines can be extracted into their own helper function that is called just the once in draw

Comment thread lib/matplotlib/figure.py Outdated
Comment on lines 243 to 245

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _get_draw_artists(self, renderer, layer):
"""Also runs apply_aspect"""
artists = self.get_children(layer=layer)

Once the apply_aspect portions of this method are extracted, the rest of this method can be made pretty generic by adding layer as a parameter and adding per-layer functionality to self.get_children

Comment thread lib/matplotlib/figure.py Outdated
Comment on lines +262 to +298
def _draw_base_layer(self, renderer):
"""
Draw the base layer: all non-overlay children, sorted by zorder.

This is the first of the two passes in `.Figure.draw`. It draws
every artist that was added through the normal insertion path
(i.e. not via ``_overlay=True``).

Parameters
----------
renderer : `.RendererBase`
"""
artists = self._get_draw_artists(renderer)
mimage._draw_list_compositing_images(
renderer, self, artists, self.suppressComposite)

def _draw_overlay_layer(self, renderer):
"""
Draw the overlay layer: artists added with ``_overlay=True``.

The overlay is transparent — no figure or axes patch is drawn before
these artists. By default ``_overlay_children`` is empty, making this
a no-op that preserves backward-compatible behaviour.

Parameters
----------
renderer : `.RendererBase`
"""
artists = [
a for a in self._overlay_children if not a.get_animated()
]
if not artists:
return
artists.sort(key=lambda a: a.get_zorder())
mimage._draw_list_compositing_images(
renderer, self, artists, self.suppressComposite)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once _get_draw_artists accepts a layer argument and the apply_aspect portions are extracted, these two methods can be made generic by accepting a layer argument that gets passed on to _get_draw_artists

Additionally, I would suggest adding a render.open_group(layer) (this doesn't actually do much outside of SVGs, but will give a collapsible/able to be hidden section per layer, which helps differentiate them)

Comment thread lib/matplotlib/figure.py Outdated
frameon = property(get_frameon, set_frameon)

def add_artist(self, artist, clip=False):
def add_artist(self, artist, clip=False, *, _overlay=False):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def add_artist(self, artist, clip=False, *, _overlay=False):
def add_artist(self, artist, clip=False, *, layer=None):

We can move towards making layer a generic thing instead of a boolean "is overlay"/"not overlay"

Comment thread lib/matplotlib/figure.py Outdated
Comment on lines +211 to +213
self._children = [] # All artists except SubFigure and Axes
self._overlay_children = [] # Artists drawn in overlay pass
# (transparent, no patch)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider tracking children as a dictionary mapping Layer name to lists of child artists

I would be careful about changing self._children directly, as that is used in a number of places

So something like self._children_by_layer = {"base": self._children, "overlay = []}

Comment thread lib/matplotlib/figure.py
Comment on lines 649 to 653
artist.set_figure(self)
self._children.append(artist)
artist._remove_method = self._children.remove
target = self._overlay_children if _overlay else self._children
target.append(artist)
artist._remove_method = target.remove

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you have a dictionary for the _children_by_layer this code should become slightly simpler (as well as other segments that mirror this code elsewhere)

Comment thread lib/matplotlib/figure.py Outdated
try:
renderer.open_group('subfigure', gid=self.get_gid())
# Pass 1: base layer (patch + all non-overlay children)
self.patch.draw(renderer)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that once the above things are implemented, then patch can be its own layer that needs less special casing

It becomes just a layer that has a single artist (self.patch) which does not appear in any other layer, and is drawn first, resulting in less need to specifically reject self.patch elsewhere.

Comment thread lib/matplotlib/figure.py Outdated
Comment on lines 344 to 354

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_children can grow a parameter to get specifically the children from a specific layer

By default it should return all of the children from all of the layers

In its current form, most of the lists that are expanded are already filtered from self._children. As such, it will only pick up things from the base layer.

The way this returns will affect the order of the artists listed, and that may be useful to preserve.

@Vikash-Kumar-23

Copy link
Copy Markdown
Contributor Author

@ksunden @story645 Thanks for the feedback! I've updated the architecture based on your suggestion to make self.patch its own layer instead of special-casing it.

Here are the updates pushed in the latest commit:

  1. Layer Dictionary: Replaced the hardcoded _overlay_children list and _overlay boolean flags with a more scalable _children_by_layer dictionary and a string layer argument.
  2. Dedicated Patch Layer: self.patch is now fully integrated into the layer system inside its own "patch" layer.
  3. Generic Draw Method: Replaced the hardcoded _draw_base_layer and _draw_overlay_layer methods with a single generic _draw_layer(renderer, layer) method. The draw() sequence for both Figure and SubFigure is now explicitly: "patch" -> "base" -> "overlay".
  4. get_children: Updated get_children(layer=None) so it returns artists specific to the requested layer.
    When layer=None is passed, it safely returns the background patch followed by all the base and overlay artists

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants