Heightmaps¶

This notebook introduces surfaces parameterized by heightmaps.

In [1]:
# !pip install git+https://github.com/tum-pbs/PhiFlow@3.0
from phi.flow import *

Heightmaps can be used in 2D or 3D. They encode a grid surface made up of lines or planes, respectively. The grid vertices are equally spaced and the displacement (height) of each point is specified via a 1D or 2D tensor.

The following code creates a heightmap in 2D from a list of height values.

In [2]:
height = wrap([.1, .02, 0, 0, 1, .95, .8, .5, 0], spatial('x'))
bounds = Box(x=2, y=1)
heightmap = geom.Heightmap(height, bounds, max_dist=.1)
plot(heightmap)
Out[2]:

The heightmap surface separates the inside from the outside. Whether the inside is below or above the height values can be set in the Heightmap constructor.

In [3]:
is_inside = CenteredGrid(lambda loc: heightmap.lies_inside(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(is_inside)
/opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_matplotlib/_matplotlib_plots.py:167: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout()  # because subplot titles can be added after figure creation
Out[3]:

While the inside check is always exact, the distance from the surface is an approximation. For small distances, the face directly underneath a point will usually be closest but for farther points this is not always true. To account for this, each face links to one other face that will be queried for larger distances. Finding these secondary faces is performed during heightmap construction where the parameter max_dist influences which other faces are the most important to link.

Above, we set max_dist=0.1. In the following cell, we visualize the distance from the surface.

In [4]:
distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(distance, heightmap, overlay='args')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[4], line 1
----> 1 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
      2 plot(distance, heightmap, overlay='args')

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_grid.py:75, in CenteredGrid(values, boundary, bounds, resolution, extrapolation, convert, **resolution_)
     73     values = sample(values, elements)
     74 elif callable(values):
---> 75     values = sample_function(values, elements, 'center', extrapolation)
     76 else:
     77     if isinstance(values, (tuple, list)) and len(values) == resolution.rank:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:869, in sample_function(f, elements, at, extrapolation)
    867     values = math.map_s2b(f)(*pos.vector)
    868 else:
--> 869     values = math.map_s2b(f)(pos)
    870 assert isinstance(values, math.Tensor), f"values function must return a Tensor but returned {type(values)}"
    871 return values

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_functional.py:1200, in map_types.<locals>.retyped_f(*args, **kwargs)
   1198     retyped_kwarg, input_types = forward_retype(v, input_types)
   1199     retyped_kwargs[k] = retyped_kwarg
-> 1200 output = f(*retyped_args, **retyped_kwargs)
   1201 restored_output = reverse_retype(output, input_types)
   1202 return restored_output

Cell In[4], line 1, in <lambda>(loc)
----> 1 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
      2 plot(distance, heightmap, overlay='args')

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:218, in Heightmap.approximate_signed_distance(self, location)
    217 def approximate_signed_distance(self, location: Tensor) -> Tensor:
--> 218     return self.approximate_closest_surface(location)[0]

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:158, in Heightmap.approximate_closest_surface(self, location)
    156 # --- use closest face from considered ---
    157 delta = math.where(projects_onto_face, proj_delta, delta_highest)
--> 158 return math.at_min((distances, delta, normals, offsets, face_idx), key=abs(distances), dim=batch('consider') & instance(self).as_batch())

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1941, in at_min(value, key, dim)
   1939 if not shape(key).only(dim):
   1940     return value
-> 1941 idx = argmin(key, dim)
   1942 return slice_(value, idx)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2034, in argmin(x, dim, index_dim)
   2032     multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes)
   2033     return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)])
-> 2034 return broadcast_op(uniform_argmin, [x], broadcast)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1149, in broadcast_op(operation, tensors, iter_dims, no_return)
   1147 iter_dims = broadcast_dims(*tensors) if iter_dims is None else iter_dims
   1148 if len(iter_dims) == 0:
-> 1149     return operation(*tensors)
   1150 else:
   1151     if isinstance(iter_dims, SHAPE_TYPES):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2032, in argmin.<locals>.uniform_argmin(x)
   2030 v_native = x._reshaped_native([keep - broadcast, dims])
   2031 idx_native = x.backend.argmin(v_native, 1, keepdims=True)
-> 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes)
   2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)])

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/backend/_numpy_backend.py:319, in NumPyBackend.unravel_index(self, flat_index, shape)
    318 def unravel_index(self, flat_index, shape):
--> 319     return np.stack(np.unravel_index(flat_index, shape), -1)

TypeError: 'NoneType' object cannot be interpreted as an integer

With max_dist=0.1, the values further than 0.1 away from the cliff get inaccurate distances. Let's increase `max_dist´ to see the difference.

In [5]:
heightmap = geom.Heightmap(height, bounds, max_dist=1)
distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(distance, heightmap, overlay='args')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 2
      1 heightmap = geom.Heightmap(height, bounds, max_dist=1)
----> 2 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
      3 plot(distance, heightmap, overlay='args')

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_grid.py:75, in CenteredGrid(values, boundary, bounds, resolution, extrapolation, convert, **resolution_)
     73     values = sample(values, elements)
     74 elif callable(values):
---> 75     values = sample_function(values, elements, 'center', extrapolation)
     76 else:
     77     if isinstance(values, (tuple, list)) and len(values) == resolution.rank:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:869, in sample_function(f, elements, at, extrapolation)
    867     values = math.map_s2b(f)(*pos.vector)
    868 else:
--> 869     values = math.map_s2b(f)(pos)
    870 assert isinstance(values, math.Tensor), f"values function must return a Tensor but returned {type(values)}"
    871 return values

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_functional.py:1200, in map_types.<locals>.retyped_f(*args, **kwargs)
   1198     retyped_kwarg, input_types = forward_retype(v, input_types)
   1199     retyped_kwargs[k] = retyped_kwarg
-> 1200 output = f(*retyped_args, **retyped_kwargs)
   1201 restored_output = reverse_retype(output, input_types)
   1202 return restored_output

Cell In[5], line 2, in <lambda>(loc)
      1 heightmap = geom.Heightmap(height, bounds, max_dist=1)
----> 2 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
      3 plot(distance, heightmap, overlay='args')

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:218, in Heightmap.approximate_signed_distance(self, location)
    217 def approximate_signed_distance(self, location: Tensor) -> Tensor:
--> 218     return self.approximate_closest_surface(location)[0]

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:158, in Heightmap.approximate_closest_surface(self, location)
    156 # --- use closest face from considered ---
    157 delta = math.where(projects_onto_face, proj_delta, delta_highest)
--> 158 return math.at_min((distances, delta, normals, offsets, face_idx), key=abs(distances), dim=batch('consider') & instance(self).as_batch())

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1941, in at_min(value, key, dim)
   1939 if not shape(key).only(dim):
   1940     return value
-> 1941 idx = argmin(key, dim)
   1942 return slice_(value, idx)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2034, in argmin(x, dim, index_dim)
   2032     multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes)
   2033     return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)])
-> 2034 return broadcast_op(uniform_argmin, [x], broadcast)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1149, in broadcast_op(operation, tensors, iter_dims, no_return)
   1147 iter_dims = broadcast_dims(*tensors) if iter_dims is None else iter_dims
   1148 if len(iter_dims) == 0:
-> 1149     return operation(*tensors)
   1150 else:
   1151     if isinstance(iter_dims, SHAPE_TYPES):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2032, in argmin.<locals>.uniform_argmin(x)
   2030 v_native = x._reshaped_native([keep - broadcast, dims])
   2031 idx_native = x.backend.argmin(v_native, 1, keepdims=True)
-> 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes)
   2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)])

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/backend/_numpy_backend.py:319, in NumPyBackend.unravel_index(self, flat_index, shape)
    318 def unravel_index(self, flat_index, shape):
--> 319     return np.stack(np.unravel_index(flat_index, shape), -1)

TypeError: 'NoneType' object cannot be interpreted as an integer

Now larger distances are covered as well. This runs with the same number of computations but trades off accuracy closer to the surface.

We can additionally query the direction to the closest surface point as well as the corresponding normal vector.

Let's plot these vectors for random points inside (orange) and outside (red) of the heightmap.

In [6]:
points = Box(x=3, y=(-1, 1)).sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
plot({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
      'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
Out[6]:

Vertical Heightmaps¶

The grid orientation is determined by which spatial dimension is not part of the grid. E.g. to create a vertical heightmap, the displacement values should be listed along the y direction.

In [7]:
height = wrap([.1, .02, 0, 0, 1, .95, .8, .5, 0], spatial('y'))
bounds = Box(x=1, y=2)
plot(geom.Heightmap(height, bounds, max_dist=.1))
Out[7]:

2D Heightmaps¶

Adding a third dimension z yields a 2D heightmap in 3D space. Here, we create height values from a function, but the rest stays the same.

In [8]:
bounds = Box(x=2, y=2, z=1)
height = CenteredGrid(lambda pos: math.exp(-math.vec_squared(pos-1) * 3), 0, bounds['x,y'], x=10, y=10).values
heightmap = geom.Heightmap(height, bounds, max_dist=.1)
show(heightmap)
/tmp/ipykernel_2627/959595594.py:2: DeprecationWarning: phiml.math.vec_squared is deprecated in favor of phiml.math.squared_norm
  height = CenteredGrid(lambda pos: math.exp(-math.vec_squared(pos-1) * 3), 0, bounds['x,y'], x=10, y=10).values
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[8], line 4
      2 height = CenteredGrid(lambda pos: math.exp(-math.vec_squared(pos-1) * 3), 0, bounds['x,y'], x=10, y=10).values
      3 heightmap = geom.Heightmap(height, bounds, max_dist=.1)
----> 4 show(heightmap)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:58, in show(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
     56 kwargs = locals()
     57 del kwargs['fields']
---> 58 fig = plot(*fields, **kwargs)
     59 plots = get_plots_by_figure(fig)
     60 if isinstance(fig, Tensor):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:322, in plot(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
    320     for i, f in enumerate(fields):
    321         idx = indices[pos][i]
--> 322         plots.plot(f, figure, axes[pos], subplots[pos], min_val, max_val, show_color_bar, color[pos][i], alpha[idx], err[idx])
    323 plots.finalize(figure)
    324 LAST_FIGURE[0] = figure

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis_base.py:382, in PlottingLibrary.plot(self, data, figure, subplot, space, *args, **kwargs)
    380 for recipe in self.recipes:
    381     if recipe.can_plot(data, space):
--> 382         recipe.plot(data, figure, subplot, space, *args, **kwargs)
    383         return
    384 raise NotImplementedError(f"No {self.name} recipe found for {data}. Recipes: {self.recipes}")

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_dash/_plotly_plots.py:449, in Object3D.plot(self, data, figure, subplot, space, min_val, max_val, show_color_bar, color, alpha, err)
    447 for i in (channel(data.geometry) - 'vector').meshgrid():
    448     if instance(data.geometry) in data.values:
--> 449         mesh: Mesh = data.geometry[i]._surface_mesh  # batched mesh
    450         values = data.values[i][spatial(data.geometry).first_index()]
    451     else:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:509, in Geometry.__getattr__(self, name)
    507 if hasattr(self.__class__, name):
    508     raise RuntimeError(f"Evaluating {self.__class__.__name__}.{name} failed with an error.")
--> 509 raise AttributeError(f"{self.__class__.__name__}.{name}")

AttributeError: Heightmap._surface_mesh

Again, we can sample directions and distances from the surface.

In [9]:
points = bounds.sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
      'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[9], line 3
      1 points = bounds.sample_uniform(instance(points=200))
      2 sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
----> 3 show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
      4       'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:58, in show(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
     56 kwargs = locals()
     57 del kwargs['fields']
---> 58 fig = plot(*fields, **kwargs)
     59 plots = get_plots_by_figure(fig)
     60 if isinstance(fig, Tensor):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:322, in plot(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
    320     for i, f in enumerate(fields):
    321         idx = indices[pos][i]
--> 322         plots.plot(f, figure, axes[pos], subplots[pos], min_val, max_val, show_color_bar, color[pos][i], alpha[idx], err[idx])
    323 plots.finalize(figure)
    324 LAST_FIGURE[0] = figure

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis_base.py:382, in PlottingLibrary.plot(self, data, figure, subplot, space, *args, **kwargs)
    380 for recipe in self.recipes:
    381     if recipe.can_plot(data, space):
--> 382         recipe.plot(data, figure, subplot, space, *args, **kwargs)
    383         return
    384 raise NotImplementedError(f"No {self.name} recipe found for {data}. Recipes: {self.recipes}")

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_dash/_plotly_plots.py:449, in Object3D.plot(self, data, figure, subplot, space, min_val, max_val, show_color_bar, color, alpha, err)
    447 for i in (channel(data.geometry) - 'vector').meshgrid():
    448     if instance(data.geometry) in data.values:
--> 449         mesh: Mesh = data.geometry[i]._surface_mesh  # batched mesh
    450         values = data.values[i][spatial(data.geometry).first_index()]
    451     else:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:509, in Geometry.__getattr__(self, name)
    507 if hasattr(self.__class__, name):
    508     raise RuntimeError(f"Evaluating {self.__class__.__name__}.{name} failed with an error.")
--> 509 raise AttributeError(f"{self.__class__.__name__}.{name}")

AttributeError: Heightmap._surface_mesh

Combining Heightmaps¶

Like all Geometry objects, heightmaps can be merged using the union function. If we only pass Heightmap objects with the same grid resolution, ΦFlow will automatically vectorize computations so that the latency does not increase.

We can use this to create complex obstacles with detailed surfaces on all sides or to create cavities.

In [10]:
height = (1 - math.linspace(-1, 1, spatial(x=10)) ** 2) ** .5
upper_heightmap = geom.Heightmap(height, Box(x=(-1, 1), y=None), max_dist=.1, fill_below=False, extrapolation=0)
lower_heightmap = geom.Heightmap(-height, Box(x=(-1, 1), y=None), max_dist=.1, fill_below=True, extrapolation=0)
heightmap = union(lower_heightmap, upper_heightmap)

points = Box(x=(-2, 2), y=(-2, 2)).sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
      'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
---------------------------------------------------------------------------
IncompatibleShapes                        Traceback (most recent call last)
Cell In[10], line 8
      6 points = Box(x=(-2, 2), y=(-2, 2)).sample_uniform(instance(points=200))
      7 sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
----> 8 show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
      9       'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:58, in show(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
     56 kwargs = locals()
     57 del kwargs['fields']
---> 58 fig = plot(*fields, **kwargs)
     59 plots = get_plots_by_figure(fig)
     60 if isinstance(fig, Tensor):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:252, in plot(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields)
    250 ncols = uniform_bound(col_dims).volume
    251 assert nrows * ncols <= max_subfigures, f"Too many subfigures ({nrows * ncols}) for max_subfigures={max_subfigures}. If you want to plot this many subfigures, increase the limit."
--> 252 positioning, indices = layout_sub_figures(data, row_dims, col_dims, animate, overlay, 0, 0)
    253 # --- Process arguments ---
    254 plots = default_plots(positioning) if lib is None else get_plots(lib)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:375, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index)
    373     offset += shape(e).only(row_dims).volume
    374 elif dim0.only(col_dims):
--> 375     layout_sub_figures(e, row_dims, col_dims, animate, overlay, offset_row, offset_col + offset, positioning, indices, index)
    376     offset += shape(e).only(col_dims).volume
    377 else:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:375, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index)
    373     offset += shape(e).only(row_dims).volume
    374 elif dim0.only(col_dims):
--> 375     layout_sub_figures(e, row_dims, col_dims, animate, overlay, offset_row, offset_col + offset, positioning, indices, index)
    376     offset += shape(e).only(col_dims).volume
    377 else:

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:362, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index)
    359 if dim0.only(overlay):
    360     for overlay_index in dim0.only(overlay).meshgrid(names=True):  # overlay these fields
    361         # ToDo expand constants along rows/cols
--> 362         layout_sub_figures(data[overlay_index], row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, {**base_index, **overlay_index})
    363     return positioning, indices
    364 elif dim0.only(animate):

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:382, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index)
    380 # --- data must be a plottable object ---
    381 data = to_field(data)
--> 382 overlay = data.shape.only(overlay)
    383 animate = data.shape.only(animate).without(overlay)
    384 row_shape = data.shape.only(row_dims).without(animate).without(overlay)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_field.py:211, in Field.shape(self)
    209     return batch(self.geometry) & self.resolution & non_dual(self.values).without(self.resolution) & self.geometry.shape['vector']
    210 set_shape = self.geometry.sets[self.sampled_at]
--> 211 return batch(self.geometry) & (channel(self.geometry) - 'vector') & set_shape & self.values.shape

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:1901, in MixedShape.__and__(self, other)
   1899     dims = {**self.dims, **merged.dims}
   1900     return replace(self, dims=dims, **{other.dim_type: merged})
-> 1901 return merge_shapes(self, other)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:2549, in merge_shapes(allow_varying_sizes, *objs)
   2547 b = pure_merge(*[s.batch for s in shapes], allow_varying_sizes=allow_varying_sizes)
   2548 d = pure_merge(*[s.dual for s in shapes], allow_varying_sizes=allow_varying_sizes)
-> 2549 i = pure_merge(*[s.instance for s in shapes], allow_varying_sizes=allow_varying_sizes)
   2550 s = pure_merge(*[s.spatial for s in shapes], allow_varying_sizes=allow_varying_sizes)
   2551 c = pure_merge(*[s.channel for s in shapes], allow_varying_sizes=allow_varying_sizes)

File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:2584, in pure_merge(allow_varying_sizes, *shapes)
   2582 else:
   2583     if not sizes_match:
-> 2584         raise IncompatibleShapes(f"Cannot merge shapes {shapes} because dimension '{dim.name}' exists with different sizes.", *shapes)
   2585     names1 = prev_dim.slice_names
   2586     names2 = dim.slice_names

IncompatibleShapes: Cannot merge shapes ((unionⁱ=2), (unionⁱ=1)) because dimension 'union' exists with different sizes.

Here, we set extrapolation=0 to extend the heightmap beyond its bounds. Consequently, points with x < -1 or x > 1 count as inside the mesh and are colored red correspondingly.