From 751d8d1a90a2e8ab29bd42647ce73f9e304a1669 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 6 Oct 2024 14:01:21 +0200 Subject: [PATCH 01/71] [vis] Fix Matplotlib color handling --- phi/vis/_matplotlib/_matplotlib_plots.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/phi/vis/_matplotlib/_matplotlib_plots.py b/phi/vis/_matplotlib/_matplotlib_plots.py index d3896c8b2..e7e6ecbd9 100644 --- a/phi/vis/_matplotlib/_matplotlib_plots.py +++ b/phi/vis/_matplotlib/_matplotlib_plots.py @@ -22,7 +22,7 @@ from phi.geom._transform import _EmbeddedGeometry from phi.math import Tensor, channel, spatial, instance, non_channel, Shape, reshaped_numpy, shape from phi.vis._vis_base import display_name, PlottingLibrary, Recipe, index_label, only_stored_elements, to_field - +from phiml.math import wrap colormaps = matplotlib.colormaps if hasattr(matplotlib.colormaps, 'get_cmap') else matplotlib.cm @@ -400,7 +400,7 @@ def plot(self, data: Field, figure, subplot, space: Box, min_val: float, max_val xyz = StaggeredGrid(lambda x: x, math.extrapolation.BOUNDARY, data.geometry.bounds, data.resolution).staggered_tensor().numpy(dims + ('vector',))[:-1, :-1, :-1, :] xyz = xyz.reshape(-1, 3) values = data.values.numpy(dims).flatten() - if color == 'cmap': + if wrap(color == 'cmap').all: color = 0 col = matplotlib.colors.to_rgba(_plt_col(color)) colors = np.zeros_like(values)[..., None] + col @@ -451,7 +451,7 @@ def plot(self, data: Field, figure, subplot, space: Box, min_val: float, max_val x, y = reshaped_numpy(c_data.center[dims], [vector, c_data.shape.without('vector')]) u, v = reshaped_numpy(c_data.values.vector[dims], [vector, c_data.shape.without('vector')]) color_i = color[idx] - if color[idx] == 'cmap': + if (color[idx] == 'cmap').all: col = _next_line_color(subplot, kind='collections') # ToDo elif color[idx].shape: col = [_plt_col(c) for c in color_i.numpy(c_data.shape.non_channel).reshape(-1)] @@ -494,7 +494,7 @@ def plot(self, data: Field, figure, subplot, space: Box, min_val: float, max_val x = x[:, 0] y = y[0, :] u, v = reshaped_numpy(data.values.vector[vector.item_names[0]], [vector, *data.shape.without('vector')]) - if color == 'cmap': + if wrap(color == 'cmap').all: col = reshaped_numpy(math.vec_length(data.values), [*data.shape.without('vector')]).T elif color.shape: col = [_plt_col(c) for c in color.numpy(data.shape.non_channel).reshape(-1)] @@ -619,7 +619,7 @@ def _plot_points(axis: Axes, data: Field, dims: tuple, vector: Shape, color: Ten data = Field(sdf_grid, math.NAN, 0) data = only_stored_elements(data) x, y = reshaped_numpy(data.points.vector[dims], ['vector', non_channel(data)]) - if color == 'cmap': + if wrap(color == 'cmap').all: values = reshaped_numpy(data.values, [non_channel(data)]) mpl_colors = add_color_bar(axis, values, min_val, max_val) single_color = False @@ -690,7 +690,7 @@ def _plot_points(axis: Axes, data: Field, dims: tuple, vector: Shape, color: Ten p1, p2 = edges.index x1, y1 = reshaped_numpy(data.graph.center[p1], ['vector', instance]) x2, y2 = reshaped_numpy(data.graph.center[p2], ['vector', instance]) - if color == 'cmap': + if wrap(color == 'cmap').all: edge_val = reshaped_numpy(edge_val, [instance]) edge_colors = add_color_bar(axis, edge_val, min_val, max_val) if edge_val.min() == edge_val.max(): @@ -877,7 +877,7 @@ def _plt_col(col): def matplotlib_colors(color: Tensor, dims: Shape, default=None) -> Union[list, None]: - if color.rank == 0 and color == 'cmap': + if color.rank == 0 and wrap(color == 'cmap').all: if default is None: return None else: From ad492c15596b23168b58b685ff929871fd501ab5 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 12:06:11 +0200 Subject: [PATCH 02/71] [vis] Allow for variable titles in animations --- phi/vis/_dash/_plotly_plots.py | 123 +++++++++++++++++++++-- phi/vis/_matplotlib/_matplotlib_plots.py | 14 ++- phi/vis/_vis.py | 9 +- phi/vis/_vis_base.py | 9 +- 4 files changed, 133 insertions(+), 22 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index 5301ddd96..3a3897835 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -4,7 +4,7 @@ import warnings import webbrowser from numbers import Number -from typing import Tuple, Any, Dict, List, Callable, Union +from typing import Tuple, Any, Dict, List, Callable, Union, Optional import numpy import numpy as np @@ -26,7 +26,7 @@ from phi.math import Tensor, spatial, channel, non_channel from phi.vis._dash.colormaps import COLORMAPS from phi.vis._plot_util import smooth_uniform_curve, down_sample_curve -from phi.vis._vis_base import PlottingLibrary, Recipe, is_jupyter +from phi.vis._vis_base import PlottingLibrary, Recipe, is_jupyter, display_name class PlotlyPlots(PlottingLibrary): @@ -39,12 +39,10 @@ def create_figure(self, rows: int, cols: int, subplots: Dict[Tuple[int, int], Box], - titles: Dict[Tuple[int, int], str], log_dims: Tuple[str, ...], plt_params: Dict[str, Any]) -> Tuple[Any, Dict[Tuple[int, int], Any]]: - titles = [titles.get((r, c), None) for r in range(rows) for c in range(cols)] specs = [[{'type': 'xy' if subplots.get((row, col), Box()).spatial_rank < 3 else 'surface'} for col in range(cols)] for row in range(rows)] - fig = self.current_figure = make_subplots(rows=rows, cols=cols, subplot_titles=titles, specs=specs) + fig = self.current_figure = make_subplots(rows=rows, cols=cols, specs=specs) for (row, col), bounds in subplots.items(): subplot = fig.get_subplot(row + 1, col + 1) if bounds.spatial_rank == 1: @@ -64,7 +62,19 @@ def create_figure(self, fig.update_layout(height=size[1] * 70) # 70 approximately matches matplotlib but it's not consistent return fig, {pos: (pos[0]+1, pos[1]+1) for pos in subplots.keys()} - def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool): + def set_title(self, title, figure: go.Figure, subplot): + if subplot is not None: + subplot = figure.get_subplot(*subplot) + if hasattr(subplot, 'domain'): + domain = subplot.domain.x, subplot.domain.y + else: + domain = [subplot.xaxis.domain, subplot.yaxis.domain] + annotation = _build_subplot_title_annotations([title], domain) + figure.layout.annotations += tuple(annotation) + else: + figure.update_layout(title_text=title) + + def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool, time_axis: Optional[str]): figures = [] for frame in range(frame_count): frame_fig = go.Figure(fig) @@ -72,20 +82,21 @@ def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval plot_frame_function(frame_fig, frame) figures.append(frame_fig) frames = [go.Frame(data=fig.data, layout=fig.layout, name=f'frame{i}') for i, fig in enumerate(figures)] - anim = go.Figure(data=figures[0].data, frames=frames) + anim = go.Figure(data=figures[0].data, layout=figures[0].layout, frames=frames) anim._phi_size = fig._phi_size if interactive: + names = [f.layout.title.text if f.layout.title.text else f'{i}' for i, f in enumerate(figures)] anim.update_layout( updatemenus=[{ 'buttons': [ { 'args': [None, {'frame': {'duration': 500, 'redraw': True}, 'fromcurrent': True}], - 'label': 'Play', + 'label': '⏵', 'method': 'animate' }, { 'args': [[None], {'frame': {'duration': 0, 'redraw': True}, 'mode': 'immediate', 'transition': {'duration': 0}}], - 'label': 'Pause', + 'label': '⏸', 'method': 'animate' } ], @@ -104,7 +115,7 @@ def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval 'xanchor': 'left', 'currentvalue': { 'font': {'size': 20}, - 'prefix': 'Frame:', + 'prefix': display_name(time_axis) + " ", 'visible': True, 'xanchor': 'right' }, @@ -115,7 +126,7 @@ def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval 'y': 0, 'steps': [{ 'args': [[f'frame{i}'], {'frame': {'duration': interval, 'redraw': True}, 'mode': 'immediate', 'transition': {'duration': interval}}], - 'label': f'Frame {i}', + 'label': names[i], 'method': 'animate' } for i in range(frame_count)] }] @@ -809,6 +820,96 @@ def join_curves(curves: List[np.ndarray]) -> np.ndarray: return np.concatenate(curves, -2) +def _build_subplot_title_annotations(subplot_titles, list_of_domains, title_edge="top", offset=0): # copied from plotly for future compatibility + # If shared_axes is False (default) use list_of_domains + # This is used for insets and irregular layouts + # if not shared_xaxes and not shared_yaxes: + x_dom = list_of_domains[::2] + y_dom = list_of_domains[1::2] + subtitle_pos_x = [] + subtitle_pos_y = [] + + if title_edge == "top": + text_angle = 0 + xanchor = "center" + yanchor = "bottom" + + for x_domains in x_dom: + subtitle_pos_x.append(sum(x_domains) / 2.0) + for y_domains in y_dom: + subtitle_pos_y.append(y_domains[1]) + + yshift = offset + xshift = 0 + elif title_edge == "bottom": + text_angle = 0 + xanchor = "center" + yanchor = "top" + + for x_domains in x_dom: + subtitle_pos_x.append(sum(x_domains) / 2.0) + for y_domains in y_dom: + subtitle_pos_y.append(y_domains[0]) + + yshift = -offset + xshift = 0 + elif title_edge == "right": + text_angle = 90 + xanchor = "left" + yanchor = "middle" + + for x_domains in x_dom: + subtitle_pos_x.append(x_domains[1]) + for y_domains in y_dom: + subtitle_pos_y.append(sum(y_domains) / 2.0) + + yshift = 0 + xshift = offset + elif title_edge == "left": + text_angle = -90 + xanchor = "right" + yanchor = "middle" + + for x_domains in x_dom: + subtitle_pos_x.append(x_domains[0]) + for y_domains in y_dom: + subtitle_pos_y.append(sum(y_domains) / 2.0) + + yshift = 0 + xshift = -offset + else: + raise ValueError("Invalid annotation edge '{edge}'".format(edge=title_edge)) + + plot_titles = [] + for index in range(len(subplot_titles)): + if not subplot_titles[index] or index >= len(subtitle_pos_y): + pass + else: + annot = { + "y": subtitle_pos_y[index], + "xref": "paper", + "x": subtitle_pos_x[index], + "yref": "paper", + "text": subplot_titles[index], + "showarrow": False, + "font": dict(size=16), + "xanchor": xanchor, + "yanchor": yanchor, + } + + if xshift != 0: + annot["xshift"] = xshift + + if yshift != 0: + annot["yshift"] = yshift + + if text_angle != 0: + annot["textangle"] = text_angle + + plot_titles.append(annot) + return plot_titles + + PLOTLY = PlotlyPlots() PLOTLY.recipes.extend([ LinePlot(), diff --git a/phi/vis/_matplotlib/_matplotlib_plots.py b/phi/vis/_matplotlib/_matplotlib_plots.py index e7e6ecbd9..109f405b0 100644 --- a/phi/vis/_matplotlib/_matplotlib_plots.py +++ b/phi/vis/_matplotlib/_matplotlib_plots.py @@ -1,6 +1,6 @@ import sys import warnings -from typing import Callable, Tuple, Any, Dict, Union +from typing import Callable, Tuple, Any, Dict, Union, Optional import matplotlib import matplotlib.pyplot as plt @@ -37,7 +37,6 @@ def create_figure(self, rows: int, cols: int, spaces: Dict[Tuple[int, int], Box], - titles: Dict[Tuple[int, int], str], log_dims: Tuple[str, ...], plt_params: Dict[str, Any]) -> Tuple[Any, Dict[Tuple[int, int], Any]]: size = (size[0] or 12, size[1] or 5) @@ -120,7 +119,6 @@ def create_figure(self, # subplot.set_yscale('log') if bounds.vector.item_names[2] in log_dims: axis.set_zscale('log') - axis.set_title(titles.get((row, col), None)) axes_by_pos[(row, col)] = axes[row, col] try: figure.tight_layout() @@ -128,7 +126,13 @@ def create_figure(self, warnings.warn(f"tight_layout could not be applied: {err}") return figure, axes_by_pos - def animate(self, fig: plt.Figure, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool): + def set_title(self, title: str, figure, subplot: Optional): + if subplot is None: + pass + else: + subplot.set_title(title) + + def animate(self, fig: plt.Figure, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool, time_axis: Optional[str]): if 'ipykernel' in sys.modules: rc('animation', html='html5') @@ -160,7 +164,7 @@ def clear_and_plot(frame: int): return animation.FuncAnimation(fig, clear_and_plot, repeat=repeat, frames=frame_count, interval=interval) def finalize(self, figure): - pass + plt.tight_layout() # because subplot titles can be added after figure creation def close(self, figure): if isinstance(figure, plt.Figure): diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index 7d639c4de..b19ebb8b4 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -10,7 +10,7 @@ from ._user_namespace import get_user_namespace, UserNamespace, DictNamespace from ._viewer import create_viewer, Viewer from ._vis_base import Control, value_range, Action, VisModel, Gui, PlottingLibrary, common_index, to_field, \ - get_default_limits, uniform_bound, is_jupyter, requires_color_map + get_default_limits, uniform_bound, is_jupyter, requires_color_map, display_name from ._vis_base import title_label from .. import math from ..field import Scene, Field @@ -376,16 +376,18 @@ def plot(*fields: Union[Field, Tensor, Geometry, list, tuple, dict], # --- animate or plot --- figures = [] for plot_idx in fig_shape.meshgrid(): - figure, axes = plots.create_figure(size, nrows, ncols, subplots, title_by_subplot, log_dims, plt_params) + figure, axes = plots.create_figure(size, nrows, ncols, subplots, log_dims, plt_params) if animate: def plot_frame(figure, frame: int): for pos, fields in positioning.items(): + plots.set_title(title_by_subplot[pos], figure, axes[pos]) + plots.set_title(display_name(animate.item_names[0][frame]) if animate.item_names[0] else None, figure, None) for i, f in enumerate(fields): idx = indices[pos][i] f = f[{animate.name: frame}] plots.plot(f, figure, axes[pos], subplots[pos], min_val, max_val, show_color_bar, color[pos][i], alpha[idx], err[idx]) plots.finalize(figure) - anim = plots.animate(figure, animate.size, plot_frame, frame_time, repeat, interactive=True) + anim = plots.animate(figure, animate.size, plot_frame, frame_time, repeat, interactive=True, time_axis=animate.name) if is_jupyter(): plots.close(figure) LAST_FIGURE[0] = anim @@ -394,6 +396,7 @@ def plot_frame(figure, frame: int): figures.append(anim) else: # non-animated plot for pos, fields in positioning.items(): + plots.set_title(title_by_subplot[pos], figure, axes[pos]) for i, f in enumerate(fields): idx = indices[pos][i] plots.plot(f, figure, axes[pos], subplots[pos], min_val, max_val, show_color_bar, color[pos][i], alpha[idx], err[idx]) diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index ee0105676..229a2be31 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -343,7 +343,6 @@ def create_figure(self, rows: int, cols: int, spaces: Dict[Tuple[int, int], Box], - titles: Dict[Tuple[int, int], str], log_dims: Tuple[str, ...], plt_params: Dict[str, Any]) -> Tuple[Any, Dict[Tuple[int, int], Any]]: """ @@ -353,7 +352,6 @@ def create_figure(self, cols: Number of sub-figures laid out horizontally. spaces: Axes and range per sub-plot: `(x,y) -> Box`. Only subplot locations contained as keys should be plotted. To indicate automatic limit, the box will have a lower or upper limit of -inf or inf, respectively. - titles: Subplot titles. log_dims: Dimensions along which axes should be log-scaled plt_params: Additional library-specific parameters for plotting. @@ -363,7 +361,7 @@ def create_figure(self, """ raise NotImplementedError - def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool): + def animate(self, fig, frame_count: int, plot_frame_function: Callable, interval: float, repeat: bool, interactive: bool, time_axis: Optional[str]): raise NotImplementedError def finalize(self, figure): @@ -385,6 +383,11 @@ def plot(self, data, figure, subplot, space, *args, **kwargs): return raise NotImplementedError(f"No {self.name} recipe found for {data}. Recipes: {self.recipes}") + def set_title(self, title: str, figure, subplot: Optional): + """Set the title of a subplot or the current frame. + If `subplot` is None, sets the title of the figure / the current frame.""" + raise NotImplementedError + class Recipe: From 21269765cd6d04af1c51cd77bed472b99d5b893f Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 12:07:15 +0200 Subject: [PATCH 03/71] [vis] Fix animating non-stackable values --- phi/vis/_vis.py | 55 ++++++++++++++++++++++---------------------- phi/vis/_vis_base.py | 4 ++++ 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index b19ebb8b4..1d3bc45d8 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -13,7 +13,7 @@ get_default_limits, uniform_bound, is_jupyter, requires_color_map, display_name from ._vis_base import title_label from .. import math -from ..field import Scene, Field +from ..field import Scene, Field, PointCloud from ..field._scene import _slugify_filename from ..geom import Geometry, Box, embed from phiml.math import Tensor, layout, batch, Shape, concat, vec, wrap, stack @@ -359,8 +359,10 @@ def plot(*fields: Union[Field, Tensor, Geometry, list, tuple, dict], min_val = 0 max_val = max([float(abs(f.values).finite_max) for l in positioning.values() for f in l] or [0]) else: - min_val = min([float(f.values.finite_min) for l in positioning.values() for f in l] or [0]) - max_val = max([float(f.values.finite_max) for l in positioning.values() for f in l] or [0]) + fin_min = lambda t: float(math.map(lambda f: math.finite_min(f.values, shape), t, dims=object).finite_min) + fin_max = lambda t: float(math.map(lambda f: math.finite_max(f.values, shape), t, dims=object).finite_max) + min_val = min([fin_min(f) for l in positioning.values() for f in l] or [0]) + max_val = max([fin_max(f) for l in positioning.values() for f in l] or [0]) if min_val != min_val: # NaN min_val = None if max_val != max_val: # NaN @@ -440,9 +442,9 @@ def layout_sub_figures(data: Union[Tensor, Field], for overlay_index in dim0.only(overlay).meshgrid(names=True): # overlay these fields # ToDo expand constants along rows/cols layout_sub_figures(data[overlay_index], row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, {**base_index, **overlay_index}) + return positioning, indices elif dim0.only(animate): - data = math.stack(data.native(), dim0) - layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index) + pass else: elements = math.unstack(data, dim0.name) offset = 0 @@ -456,20 +458,21 @@ def layout_sub_figures(data: Union[Tensor, Field], offset += shape(e).only(col_dims).volume else: layout_sub_figures(e, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, index) - else: # --- data must be a plottable object --- - data = to_field(data) - overlay = data.shape.only(overlay) - animate = data.shape.only(animate).without(overlay) - row_shape = data.shape.only(row_dims).without(animate).without(overlay) - col_shape = data.shape.only(col_dims).without(row_dims).without(animate).without(overlay) - row_shape &= row_dims.after_gather(base_index) - col_shape &= col_dims.after_gather(base_index) - for ri, r in enumerate(row_shape.meshgrid(names=True)): - for ci, c in enumerate(col_shape.meshgrid(names=True)): - for o in overlay.meshgrid(names=True): - sub_data = data[r][c][o] - positioning.setdefault((offset_row + ri, offset_col + ci), []).append(sub_data) - indices.setdefault((offset_row + ri, offset_col + ci), []).append(dict(base_index, **r, **c, **o)) + return positioning, indices + # --- data must be a plottable object --- + data = to_field(data) + overlay = data.shape.only(overlay) + animate = data.shape.only(animate).without(overlay) + row_shape = data.shape.only(row_dims).without(animate).without(overlay) + col_shape = data.shape.only(col_dims).without(row_dims).without(animate).without(overlay) + row_shape &= row_dims.after_gather(base_index) + col_shape &= col_dims.after_gather(base_index) + for ri, r in enumerate(row_shape.meshgrid(names=True)): + for ci, c in enumerate(col_shape.meshgrid(names=True)): + for o in overlay.meshgrid(names=True): + sub_data = data[r][c][o] + positioning.setdefault((offset_row + ri, offset_col + ci), []).append(sub_data) + indices.setdefault((offset_row + ri, offset_col + ci), []).append(dict(base_index, **r, **c, **o)) return positioning, indices @@ -514,15 +517,11 @@ def layout_color(content: Dict[Tuple[int, int], List[Field]], indices: Dict[Tupl idx = indices[pos][i] if (color[idx] != None).all: # user-specified color result_pos.append(color[idx]) - elif requires_color_map(f): - result_pos.append(wrap('cmap')) - else: - channels = channel(f).without('vector') - if channels: - result_pos.append(counter + math.range_tensor(channels)) - else: - result_pos.append(wrap(counter)) - counter += channels.volume + cmap = requires_color_map(f) + channels = channel(f).without('vector') + channel_colors = counter + math.range_tensor(channels) + result_pos.append(math.where(cmap, wrap('cmap'), channel_colors)) + counter += channels.volume * math.any(~cmap, shape) return result diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index 229a2be31..1592f42b2 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -496,6 +496,8 @@ def select_channel(value: Union[Field, Tensor, tuple, list], channel: Union[str, def to_field(obj) -> Field: + if isinstance(obj, Tensor) and obj.dtype.kind == object: + return math.map(to_field, obj, dims=object) if isinstance(obj, Field): return obj if isinstance(obj, Geometry): @@ -522,6 +524,7 @@ def to_field(obj) -> Field: raise ValueError(f"Cannot plot {obj}. Tensors, geometries and fields can be plotted.") +@math.broadcast(dims=object) def get_default_limits(f: Field, all_dims: Optional[Sequence[str]], log_dims: Tuple[str], err: Tensor) -> Box: if f.is_point_cloud and f.spatial_rank == 1: # 1D: bar chart bounds = f.bounds @@ -594,6 +597,7 @@ def uniform_bound(shape: Shape): return shape.with_sizes(sizes) +@math.broadcast(dims=object) def requires_color_map(f: Field): if f.spatial_rank <= 1: return False From c7a8617728526f641820c810b162aecdaac7510b Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 12:08:02 +0200 Subject: [PATCH 04/71] [vis] Remove legacy view() and Viewer, add show_hist() --- phi/flow.py | 3 +- phi/vis/__init__.py | 3 +- phi/vis/_vis.py | 92 +++------------------------------------------ 3 files changed, 7 insertions(+), 91 deletions(-) diff --git a/phi/flow.py b/phi/flow.py index 4fd243c4a..5dc8791bb 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -24,7 +24,6 @@ from phiml.math import Shape, Tensor, DType, Solve from .geom import Geometry, Point, Sphere, Box, Cuboid, UniformGrid, Mesh, Graph from .field import Field, Grid, CenteredGrid, StaggeredGrid, mask, Noise, PointCloud, Scene, resample, GeometryMask, SoftGeometryMask, HardGeometryMask -from .vis import Viewer from .physics.fluid import Obstacle # Constants @@ -47,7 +46,7 @@ assert_close, always_close, equal, close ) from .geom import union -from .vis import show, view, control, plot +from .vis import show, control, plot # Exceptions from phiml.math import ConvergenceException, NotConverged, Diverged diff --git a/phi/vis/__init__.py b/phi/vis/__init__.py index f669268f6..89999d678 100644 --- a/phi/vis/__init__.py +++ b/phi/vis/__init__.py @@ -7,10 +7,9 @@ See the user interface documentation at https://tum-pbs.github.io/PhiFlow/Visualization.html """ -from ._viewer import Viewer from ._io import load_scalars from ._plot_util import smooth -from ._vis import view, control, show, close, action, plot, overlay, write_image, write_image as savefig +from ._vis import control, show, close, action, plot, overlay, write_image, write_image as savefig, show_hist def plot_scalars(*args, **kwargs): diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index 1d3bc45d8..423975e4d 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -64,6 +64,11 @@ def show(*fields: Union[Field, Tensor, Geometry, list, tuple, dict], return plots.show(fig) +def show_hist(data: Tensor, bins=math.instance(bins=20), weights=1, same_bins: DimFilter = None): + hist, edges, center = math.histogram(data, bins, weights, same_bins) + show(PointCloud(center, hist)) + + def close(figure=None): """ Close and destroy a figure. @@ -85,93 +90,6 @@ def close(figure=None): close_ = close -RECORDINGS = {} - - -def record(*fields: Union[str, Field]) -> Viewer: - user_namespace = get_user_namespace(1) - variables = _default_field_variables(user_namespace, fields) - viewer = create_viewer(user_namespace, variables, "record", "", scene=None, asynchronous=False, controls=(), - actions={}, log_performance=False) - viewer.post_step.append(lambda viewer: print(viewer.steps, end=" ")) - viewer.progress_unavailable.append(lambda viewer: print()) - return viewer - - -def view(*fields: Union[str, Field], - play: bool = True, - gui=None, - name: str = None, - description: str = None, - scene: Union[bool, Scene] = False, - keep_alive=True, - select: Union[str, tuple, list] = '', - framerate=None, - namespace=None, - log_performance=True, - **config) -> Viewer: - """ - Show `fields` in a graphical user interface. - - `fields` may contain instances of `Field` or variable names of top-level variables (main module or Jupyter notebook). - During loops, e.g. `view().range()`, the variable status is tracked and the GUI is updated. - - When called from a Python script, name and description may be specified in the module docstring (string before imports). - The first line is interpreted as the name, the rest as the subtitle. - If not specified, a generic name and description is chosen. - - Args: - *fields: (Optional) Contents to be displayed. Either variable names or values. - For field instances, all variables referencing the value will be shown. - If not provided, the user namespace is searched for Field variables. - play: Whether to immediately start executing loops. - gui: (Optional) Name of GUI as `str` or GUI class. - Built-in GUIs can be selected via `'dash'`, `'console'`. - See https://tum-pbs.github.io/PhiFlow/Visualization.html - name: (Optional) Name to display in GUI and use for the output directory if `scene=True`. - Will be generated from the top-level script if not provided. - description: (Optional) Description to be displayed in the GUI. - Will be generated from the top-level script if not provided. - scene: Existing `Scene` to write into or `bool`. If `True`, creates a new Scene in `~/phi/` - keep_alive: Whether the GUI should keep running even after the main thread finishes. - framerate: Target frame rate in Hz. Play will not step faster than the framerate. `None` for unlimited frame rate. - select: Dimension names along which one item to show is selected. - Dimensions may be passed as `tuple` of `str` or as comma-separated names in a single `str`. - For each `select` dimension, an associated selection slider will be created. - log_performance: Whether to measure and log the time each step takes. - If `True`, will be logged as `step_time` to `log_step_time.txt`. - **config: Additional GUI configuration arguments. - - Returns: - `Viewer` - """ - default_namespace = get_user_namespace(1) - user_namespace = default_namespace if namespace is None else DictNamespace(namespace, - title=default_namespace.get_title(), - description=default_namespace.get_description(), - reference=default_namespace.get_reference()) - variables = _default_field_variables(user_namespace, fields) - actions = dict(ACTIONS) - ACTIONS.clear() - if scene is False: - scene = None - elif scene is True: - scene = Scene.create(os.path.join("~", "phi", _slugify_filename(name or user_namespace.get_reference()))) - print(f"Created scene at {scene}") - else: - assert isinstance(scene, Scene) - name = name or user_namespace.get_title() - description = description or user_namespace.get_description() - gui = default_gui() if gui is None else get_gui(gui) - controls = tuple(c for c in sorted(CONTROL_VARS.values(), key=lambda c: c.name) if - user_namespace.get_variable(c.name) is not None) - CONTROL_VARS.clear() - viewer = create_viewer(user_namespace, variables, name, description, scene, asynchronous=gui.asynchronous, - controls=controls, actions=actions, log_performance=log_performance) - show(viewer, play=play, gui=gui, keep_alive=keep_alive, framerate=framerate, select=select, **config) - return viewer - - def _default_field_variables(user_namespace: UserNamespace, fields: tuple): names = [] values = [] From 0a056f25d3fd54df561d6592fc727eb37d3bb113 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 12:08:41 +0200 Subject: [PATCH 05/71] [field] Layout non-stackable Fields when stacking --- phi/field/_field_math.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/phi/field/_field_math.py b/phi/field/_field_math.py index 66730cb04..02517f608 100644 --- a/phi/field/_field_math.py +++ b/phi/field/_field_math.py @@ -932,6 +932,8 @@ def stack(fields: Sequence[Field], dim: Shape, dim_bounds: Box = None): """ assert all(isinstance(f, Field) for f in fields), f"All fields must be Fields of the same type but got {fields}" assert all(isinstance(f, type(fields[0])) for f in fields), f"All fields must be Fields of the same type but got {fields}" + if not all([f.geometry == fields[0].geometry or f.sampled_at != fields[0].sampled_at for f in fields]): + return math.layout(fields, dim) if any(f.boundary != fields[0].boundary for f in fields): boundary = math.stack([f.boundary for f in fields], dim) else: @@ -949,7 +951,6 @@ def stack(fields: Sequence[Field], dim: Shape, dim_bounds: Box = None): values = math.stack([f.values for f in fields], dim) return PointCloud(geometry, values, boundary) elif fields[0].is_mesh: - assert all([f.geometry == fields[0].geometry for f in fields]), f"stacking fields with different geometries is not supported. Got {[f.geometry for f in fields]}" values = math.stack([f.values for f in fields], dim) return Field(fields[0].geometry, values, boundary) raise NotImplementedError(type(fields[0])) From 65d1e312e21c97adc7cbb1805c8f51661c7cade2 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 12:12:48 +0200 Subject: [PATCH 06/71] [vis] Fix bounds of channel-only bar charts --- phi/vis/_vis_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index 1592f42b2..40a7bbb10 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -528,8 +528,10 @@ def to_field(obj) -> Field: def get_default_limits(f: Field, all_dims: Optional[Sequence[str]], log_dims: Tuple[str], err: Tensor) -> Box: if f.is_point_cloud and f.spatial_rank == 1: # 1D: bar chart bounds = f.bounds + if instance(f).volume == 1: + return Box(bounds.lower - .5, bounds.upper + .5) count = non_batch(f).non_dual.non_channel.volume - return Box(bounds.lower - bounds.size / count / 2, bounds.upper + bounds.size / count / 2) + return Box(bounds.lower - bounds.size / (count - 1) / 2, bounds.upper + bounds.size / (count - 1) / 2) if f.spatial_rank == 1 and spatial(f).rank == 1 and all_dims and len(all_dims) > 1: # Embedded 1D line if all_dims: remaining = [d for d in all_dims if d not in f.geometry.vector.item_names] From 85d7084979c916815bd76e0f3f086662a1c4da5f Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 13:04:29 +0200 Subject: [PATCH 07/71] [field] Add Field.grid --- phi/field/_field.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/phi/field/_field.py b/phi/field/_field.py index 30df20555..87f2799c9 100644 --- a/phi/field/_field.py +++ b/phi/field/_field.py @@ -70,6 +70,12 @@ def geometry(self) -> Geometry: """ return self._geometry + @property + def grid(self) -> UniformGrid: + """Cast `self.geometry` to a `phi.geom.UniformGrid`.""" + assert isinstance(self._geometry, UniformGrid), f"Geometry is not a UniformGrid but {type(self._geometry)}" + return self._geometry + @property def mesh(self) -> Mesh: """Cast `self.geometry` to a `phi.geom.Mesh`.""" From 376b6aab6342d418c56553f0c35d848a738e6349 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 13:04:47 +0200 Subject: [PATCH 08/71] [geom] Add bounding_box() --- phi/geom/__init__.py | 2 +- phi/geom/_box.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index d6746b39e..45908fed4 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -12,7 +12,7 @@ from ..math import stack, concat, pack_dims # for compatibility from ._functions import normal_from_slope from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, rotate, sample_function -from ._box import Box, BaseBox, Cuboid +from ._box import Box, BaseBox, Cuboid, bounding_box from ._sphere import Sphere from ._grid import UniformGrid, enclosing_grid from ._graph import Graph, graph diff --git a/phi/geom/_box.py b/phi/geom/_box.py index 0e178a3d0..27602290b 100644 --- a/phi/geom/_box.py +++ b/phi/geom/_box.py @@ -6,7 +6,7 @@ from phi import math from phi.math import DimFilter from phiml.math import rename_dims, vec, stack, expand, instance -from phiml.math._shape import parse_dim_order, dual, non_channel +from phiml.math._shape import parse_dim_order, dual, non_channel, non_batch from ._geom import Geometry, _keep_vector from ..math import wrap, INF, Shape, channel, Tensor from ..math.magic import slicing_dict @@ -534,7 +534,19 @@ def lies_inside(self, location: Tensor) -> Tensor: return bool_inside -def bounding_box(geometry: Geometry): +def bounding_box(geometry: Geometry | Tensor) -> Box: + """ + Builds a bounding box around `geometry` or a collection of points. + + Args: + geometry: `Geometry` object or `Tensor` of points. + + Returns: + Bounding `Box` containing only batch dims and `vector`. + """ + if isinstance(geometry, Tensor): + assert 'vector' in geometry.shape, f"When passing a Tensor to bounding_box, it needs to have a vector dimension but got {geometry.shape}" + return Box(math.min(geometry, non_batch(geometry) - 'vector'), math.max(geometry, non_batch(geometry) - 'vector')) center = geometry.center extent = geometry.bounding_half_extent() return Box(lower=center - extent, upper=center + extent) From 12dae2ff3cfa63acbdff25f2454d4f5f6f1082cd Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 13:05:25 +0200 Subject: [PATCH 09/71] [vis] Fix limits of quiver plots --- phi/vis/_matplotlib/_matplotlib_plots.py | 2 +- phi/vis/_vis_base.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/phi/vis/_matplotlib/_matplotlib_plots.py b/phi/vis/_matplotlib/_matplotlib_plots.py index 109f405b0..eedb1beea 100644 --- a/phi/vis/_matplotlib/_matplotlib_plots.py +++ b/phi/vis/_matplotlib/_matplotlib_plots.py @@ -488,7 +488,7 @@ def plot(self, data: Field, figure, subplot, space: Box, min_val: float, max_val class StreamPlot2D(Recipe): def can_plot(self, data: Field, space: Box) -> bool: - return data.spatial_rank == 2 and 'vector' in channel(data) and data.is_grid and (data.values != 0).any + return data.spatial_rank == 2 and 'vector' in channel(data) and data.is_grid and (data.values != 0).any and all(dim.size > 1 for dim in data.resolution) def plot(self, data: Field, figure, subplot, space: Box, min_val: float, max_val: float, show_color_bar: bool, color: Tensor, alpha: Tensor, err: Tensor): vector = data.geometry.shape['vector'] diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index 40a7bbb10..bbb94f2a6 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -6,7 +6,7 @@ from threading import Lock from typing import Tuple, Any, Optional, Dict, Callable, Union, Sequence -from phi import field, math +from phi import field, math, geom from phi.field import Field, Scene, PointCloud, CenteredGrid from phi.field._field_math import data_bounds from phi.geom import Box, Cuboid, Geometry, Point @@ -548,6 +548,10 @@ def get_default_limits(f: Field, all_dims: Optional[Sequence[str]], log_dims: Tu is_log = wrap([dim in log_dims for dim in f_dims], channel(vector=f_dims)) if math.equal(0, err): bounding_box = f.geometry.bounding_box() + if 'vector' in f.values.shape: + target_points = f.points + f.values.vector[list(f.geometry.shape.get_item_names('vector'))] + target_bounds = geom.bounding_box(target_points) + bounding_box = geom.union(bounding_box, target_bounds).largest('union') if value_axis: bounding_box *= Box(_=(math.finite_min(f.values), math.finite_max(f.values))) return _limits(bounding_box.center, bounding_box.half_size, is_log) From 5b30ce5d710173ae90a734f666bd7acb44c58d4f Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 7 Oct 2024 13:05:53 +0200 Subject: [PATCH 10/71] [vis] Improved Matplotlib annotation positions --- phi/vis/_matplotlib/_matplotlib_plots.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/phi/vis/_matplotlib/_matplotlib_plots.py b/phi/vis/_matplotlib/_matplotlib_plots.py index eedb1beea..9bd5a80b4 100644 --- a/phi/vis/_matplotlib/_matplotlib_plots.py +++ b/phi/vis/_matplotlib/_matplotlib_plots.py @@ -776,21 +776,21 @@ def _annotate_points(axis, points: math.Tensor, color: Tensor, alpha: Tensor, di set_ticks(axis, which_axis, reshaped_numpy(points.vector[labeled_dim.name], [shape])) return # The point labels match one of the figure axes, so they are redundant if points.shape['vector'].size == 2: - xs, ys = reshaped_numpy(points, ['vector', points.shape.without('vector')]) + np_points = points.numpy([..., 'vector']) + rel_pos = axis.transAxes.inverted().transform(axis.transData.transform(np_points)) x_view = axis.get_xlim()[1] - axis.get_xlim()[0] y_view = axis.get_ylim()[1] - axis.get_ylim()[0] - x_c = .95 * axis.get_xlim()[1] + .1 * axis.get_xlim()[0] - y_c = .95 * axis.get_ylim()[1] + .1 * axis.get_ylim()[0] - for x, y, idx, idx_n in zip(xs, ys, labeled_dims.meshgrid(), labeled_dims.meshgrid(names=True)): + for (x, y), (rx, ry), idx, idx_n in zip(np_points, rel_pos, labeled_dims.meshgrid(), labeled_dims.meshgrid(names=True)): + horizontal_align = 'right' if rx >= .5 else 'left' if axis.get_xscale() == 'log': - offset_x = x * (1 + .0003 * x_view) if x < x_c else x * (1 - .0003 * x_view) + offset_x = x * (1 + .0003 * x_view) if rx < .5 else x * (1 - .0003 * x_view) else: - offset_x = x + .11 * x_view if x < x_c else x - .26 * x_view + offset_x = x + .01 * x_view if rx < .5 else x - .01 * x_view if axis.get_yscale() == 'log': - offset_y = y * (1 + .0003 * y_view) if y < y_c else y * (1 - .0003 * y_view) + offset_y = y * (1 + .0003 * y_view) if ry < .5 else y * (1 - .0003 * y_view) else: - offset_y = y + .01 * y_view if y < y_c else y - .01 * y_view - axis.text(offset_x, offset_y, index_label(idx_n), color=_plt_col(color[idx]), alpha=float(alpha[idx])) + offset_y = y + .01 * y_view if ry < .5 else y - .01 * y_view + axis.text(offset_x, offset_y, index_label(idx_n), color=_plt_col(color[idx]), alpha=float(alpha[idx]), ha=horizontal_align) class PointCloud3D(Recipe): From 97797f5a54daee100c853ba6213db1246d97291d Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 16 Oct 2024 21:11:31 +0200 Subject: [PATCH 11/71] [geom] Add read_stl() --- phi/geom/__init__.py | 2 +- phi/geom/_mesh.py | 29 ++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index 45908fed4..1bb21ed23 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -16,7 +16,7 @@ from ._sphere import Sphere from ._grid import UniformGrid, enclosing_grid from ._graph import Graph, graph -from ._mesh import Mesh, mesh, load_su2, load_gmsh, mesh_from_numpy, build_mesh +from ._mesh import Mesh, mesh, load_su2, load_gmsh, load_stl, mesh_from_numpy, build_mesh from ._transform import embed, infinite_cylinder from ._heightmap import Heightmap from ._sdf_grid import SDFGrid, sample_sdf diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index f6b60936c..6a40cf865 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -526,8 +526,18 @@ def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance return mesh_from_numpy(points, elements, boundaries, cell_dim=cell_dim, face_format=face_format) -def mesh_from_numpy(points: Union[list, np.ndarray], - polygons: list, +def load_stl(file: str, face_dim=instance('faces')): + import stl + model = stl.mesh.Mesh.from_file(file) + points = np.reshape(model.points, (-1, 3)) + vertices, indices = np.unique(points, axis=0, return_inverse=True) + indices = np.reshape(indices, (-1, 3)) + mesh = mesh_from_numpy(vertices, indices, element_rank=2, build_faces=False, cell_dim=face_dim) + return mesh + + +def mesh_from_numpy(points: Sequence[Sequence], + polygons: Sequence[Sequence], boundaries: str | Dict[str, List[Sequence]] | None = None, element_rank: int = None, build_faces=True, @@ -931,7 +941,7 @@ def face_curvature(mesh: Mesh): # vec_curvature = math.max(v_normals, dual) - math.min(v_normals, dual) # positive / negative -def save_tri_mesh(file: str, mesh: Mesh): +def save_tri_mesh(file: str, mesh: Mesh, **extra_data): v = math.reshaped_numpy(mesh.vertices.center, [instance, 'vector']) if isinstance(mesh._elements, CompactSparseTensor): f = math.reshaped_numpy(mesh._elements._indices, [instance, dual]) @@ -939,17 +949,22 @@ def save_tri_mesh(file: str, mesh: Mesh): raise NotImplementedError print(f"Saving triangle mesh with {v.shape[0]} vertices and {f.shape[0]} faces to {file}") os.makedirs(os.path.dirname(file), exist_ok=True) - np.savez(file, vertices=v, faces=f, f_dim=instance(mesh).name, vertex_dim=instance(mesh.vertices).name, vector=mesh.vector.item_names) + np.savez(file, vertices=v, faces=f, f_dim=instance(mesh).name, vertex_dim=instance(mesh.vertices).name, vector=mesh.vector.item_names, has_extra_data=bool(extra_data), **extra_data) -def load_tri_mesh(file: str, convert=False) -> Mesh: - data = np.load(file) +def load_tri_mesh(file: str, convert=False, load_extra=()) -> Mesh | Tuple[Mesh, ...]: + data = np.load(file, allow_pickle=bool(load_extra)) f_dim = instance(str(data['f_dim'])) vertex_dim = instance(str(data['vertex_dim'])) vector = channel(vector=[str(d) for d in data['vector']]) faces = tensor(data['faces'], f_dim, spatial('vertex_list'), convert=convert) vertices = tensor(data['vertices'], vertex_dim, vector, convert=convert) - return mesh(vertices, faces, build_faces=False, build_vertex_connectivity=True, build_normals=True) + m = mesh(vertices, faces, build_faces=False, build_vertex_connectivity=True, build_normals=True) + if not load_extra: + return m + extra = [data[e] for e in load_extra] + extra = [e.tolist() if e.dtype == object else e for e in extra] + return m, *extra def decimate_tri_mesh(mesh: Mesh, factor=.1, target_max=10_000,): From b639f4824aa148780051533636d5574a04e7e9e5 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 16 Oct 2024 21:13:15 +0200 Subject: [PATCH 12/71] [field] Support stacking Fields with compatible Geometries --- phi/field/_field_math.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/phi/field/_field_math.py b/phi/field/_field_math.py index 02517f608..899ef09d6 100644 --- a/phi/field/_field_math.py +++ b/phi/field/_field_math.py @@ -932,7 +932,7 @@ def stack(fields: Sequence[Field], dim: Shape, dim_bounds: Box = None): """ assert all(isinstance(f, Field) for f in fields), f"All fields must be Fields of the same type but got {fields}" assert all(isinstance(f, type(fields[0])) for f in fields), f"All fields must be Fields of the same type but got {fields}" - if not all([f.geometry == fields[0].geometry or f.sampled_at != fields[0].sampled_at for f in fields]): + if any([f.sampled_at != fields[0].sampled_at for f in fields]): return math.layout(fields, dim) if any(f.boundary != fields[0].boundary for f in fields): boundary = math.stack([f.boundary for f in fields], dim) @@ -946,14 +946,10 @@ def stack(fields: Sequence[Field], dim: Shape, dim_bounds: Box = None): return grid(values, boundary, fields[0].bounds * dim_bounds) else: return fields[0].with_values(values).with_boundary(boundary) - elif fields[0].is_point_cloud or fields[0].is_graph: - geometry = geom.stack([f.geometry for f in fields], dim) - values = math.stack([f.values for f in fields], dim) - return PointCloud(geometry, values, boundary) - elif fields[0].is_mesh: + else: values = math.stack([f.values for f in fields], dim) - return Field(fields[0].geometry, values, boundary) - raise NotImplementedError(type(fields[0])) + geometry = fields[0].geometry if all(f.geometry == fields[0].geometry for f in fields) else math.stack([f.geometry for f in fields], dim) + return Field(geometry, values, boundary) def assert_close(*fields: Field or Tensor or Number, From b30ffe4e2440daa3c0d0cfecf63b00fbbbdff507 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Thu, 17 Oct 2024 21:22:37 +0200 Subject: [PATCH 13/71] [field,geom] Optimize Field.shape getter --- phi/field/_field.py | 2 +- phi/geom/_grid.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/phi/field/_field.py b/phi/field/_field.py index 87f2799c9..fd48a87ff 100644 --- a/phi/field/_field.py +++ b/phi/field/_field.py @@ -205,7 +205,7 @@ def shape(self) -> Shape: * The batch dimensions match the batch dimensions of this Field * The channel dimensions match the channels of this Field """ - if self.is_staggered and self.is_grid: + if self.is_grid and '~vector' in self._values.shape: return batch(self._geometry) & self.resolution & non_dual(self._values).without(self.resolution) & self._geometry.shape['vector'] set_shape = self._geometry.sets[self.sampled_at] return batch(self._geometry) & (channel(self._geometry) - 'vector') & set_shape & self._values diff --git a/phi/geom/_grid.py b/phi/geom/_grid.py index fe503abc8..e97c60c78 100644 --- a/phi/geom/_grid.py +++ b/phi/geom/_grid.py @@ -34,6 +34,8 @@ def __init__(self, resolution: Shape = None, bounds: BaseBox = None, **resolutio self._resolution = resolution.only(bounds.vector.item_names, reorder=True) # reorder only self._bounds = bounds self._shape = self._resolution & bounds.shape.non_spatial + staggered_shapes = [self._shape.spatial.with_dim_size(dim, self._shape.get_size(dim) + 1) for dim in self.vector.item_names] + self._face_shape = shape_stack(dual(vector=self.vector.item_names), *staggered_shapes) @property def resolution(self): @@ -87,8 +89,7 @@ def face_areas(self) -> Tensor: @property def face_shape(self) -> Shape: - shapes = [self._shape.spatial.with_dim_size(dim, self._shape.get_size(dim) + 1) for dim in self.vector.item_names] - return shape_stack(dual(vector=self.vector.item_names), *shapes) + return self._face_shape def interior(self) -> 'Geometry': raise GeometryException("Regular grid does not have an interior") From 2d4d6f30f31a4d7a072bcd73dac42bf7fd16fee8 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 18 Oct 2024 17:29:46 +0200 Subject: [PATCH 14/71] [geom] Add Cylinder --- phi/flow.py | 2 +- phi/geom/__init__.py | 1 + phi/geom/_cylinder.py | 244 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 phi/geom/_cylinder.py diff --git a/phi/flow.py b/phi/flow.py index 5dc8791bb..6cfea35f3 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -22,7 +22,7 @@ # Classes from phiml.math import Shape, Tensor, DType, Solve -from .geom import Geometry, Point, Sphere, Box, Cuboid, UniformGrid, Mesh, Graph +from .geom import Geometry, Point, Sphere, Box, Cuboid, Cylinder, UniformGrid, Mesh, Graph from .field import Field, Grid, CenteredGrid, StaggeredGrid, mask, Noise, PointCloud, Scene, resample, GeometryMask, SoftGeometryMask, HardGeometryMask from .physics.fluid import Obstacle diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index 1bb21ed23..d409731cc 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -14,6 +14,7 @@ from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, rotate, sample_function from ._box import Box, BaseBox, Cuboid, bounding_box from ._sphere import Sphere +from ._cylinder import Cylinder from ._grid import UniformGrid, enclosing_grid from ._graph import Graph, graph from ._mesh import Mesh, mesh, load_su2, load_gmsh, load_stl, mesh_from_numpy, build_mesh diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py new file mode 100644 index 000000000..665af8618 --- /dev/null +++ b/phi/geom/_cylinder.py @@ -0,0 +1,244 @@ +from typing import Union, Dict, Tuple, Optional, Sequence + +from phiml import math +from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, \ + maximum +from phiml.math.magic import slicing_dict +from ._geom import Geometry, _keep_vector +from ._sphere import Sphere + + +class Cylinder(Geometry): + """ + N-dimensional cylinder. + Defined by center position, radius, depth, alignment axis, rotation. + + For cylinders whose bottom and top lie outside the domain or are otherwise not needed, you may use `infinite_cylinder` instead, which simplifies computations. + """ + + def __init__(self, + center: Tensor = None, + radius: Union[float, Tensor] = None, + depth: Union[float, Tensor] = None, + rotation: Optional[Tensor] = None, + axis=-1, + variables=('center', 'radius', 'depth', 'rotation'), + **center_: Union[float, Tensor]): + """ + Args: + center: Cylinder center as `Tensor` with `vector` dimension. + The spatial dimension order should be specified in the `vector` dimension via item names. + Can be left empty to specify dimensions via kwargs. + radius: Cylinder radius as `float` or `Tensor`. + depth: Cylinder length as `float` or `Tensor`. + rotation: Rotation angle(s) or rotation matrix. + axis: The cylinder is aligned along this axis, perturbed by `rotation`. + variables: Which properties of the cylinder are variable, i.e. traced and optimizable. All by default. + **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. + """ + if center is not None: + assert isinstance(center, Tensor), f"center must be a Tensor but got {type(center).__name__}" + assert 'vector' in center.shape, f"Sphere center must have a 'vector' dimension." + assert center.shape.get_item_names('vector') is not None, f"Vector dimension must list spatial dimensions as item names. Use the syntax Sphere(x=x, y=y) to assign names." + self._center = center + else: + self._center = wrap(tuple(center_.values()), channel(vector=tuple(center_.keys()))) + self._radius = wrap(radius) + self._depth = wrap(depth) + self._rotation = None if rotation is None else rotation_matrix(rotation) + self._variables = tuple([v if v.startswith('_') else '_' + v for v in variables]) + self._axis = self._center.vector.item_names[axis] if isinstance(axis, int) else axis + assert 'vector' not in self._radius.shape, f"Cylinder radius must not vary along vector but got {radius}" + assert set(self._variables).issubset(set(self.__all_attrs__())), f"Invalid variables: {self._variables}" + assert self._axis in self._center.vector.item_names, f"Cylinder axis {self._axis} not part of vector dim {self._center.vector}" + + def __all_attrs__(self) -> tuple: + return '_center', '_radius', '_depth', '_rotation' + + def __variable_attrs__(self) -> tuple: + return self._variables + + def __value_attrs__(self) -> tuple: + return () + + @property + def shape(self) -> Shape: + if self._center is None or self._radius is None or self._depth is None: + raise RuntimeError + return self._center.shape & self._radius.shape & self._depth.shape + + @property + def radius(self) -> Tensor: + return self._radius + + @property + def center(self) -> Tensor: + return self._center + + @property + def depth(self) -> Tensor: + return self._depth + + @property + def axis(self) -> str: + return self._axis + + @property + def radial_axes(self) -> Sequence[str]: + return [d for d in self._center.vector.item_names if d != self._axis] + + @property + def rotation_matrix(self): + return self._rotation + + @property + def volume(self) -> math.Tensor: + return Sphere.volume_from_radius(self._radius, self.spatial_rank - 1) * self._depth + + @property + def up(self): + return math.rotate_vector(vec(**{d: 1 if d == self._axis else 0 for d in self._center.vector.item_names}), self._rotation) + + def lies_inside(self, location): + pos = rotate_vector(location - self._center, self._rotation, invert=True) + r = pos.vector[self.radial_axes] + h = pos.vector[self._axis] + inside = (vec_squared(r) <= self._radius**2) & (h >= -.5*self._depth) & (h <= .5*self._depth) + return math.any(inside, instance(self)) # union for instance dimensions + + def approximate_signed_distance(self, location: Union[Tensor, tuple]): + location = math.rotate_vector(location - self._center, self._rotation, invert=True) + r = location.vector[self.radial_axes] + h = location.vector[self._axis] + top_h = .5*self._depth + bot_h = -.5*self._depth + # --- Compute distances --- + radial_outward = normalize(r, epsilon=1e-5) + surf_r = radial_outward * self._radius + radial_dist2 = vec_squared(r) + inside_cyl = radial_dist2 <= self._radius**2 + clamped_r = where(inside_cyl, r, surf_r) + # --- Closest point on bottom / top --- + sgn_dist_side = abs(h) - top_h + # --- Closest point on cylinder --- + sgn_dist_cyl = length(r) - self._radius + # inside (all <= 0) -> largest SDF, outside (any > 0) -> largest positive SDF + sgn_dist = maximum(sgn_dist_cyl, sgn_dist_side) + return math.min(sgn_dist, instance(self)) + + def approximate_closest_surface(self, location: Tensor): + location = math.rotate_vector(location - self._center, self._rotation, invert=True) + r = location.vector[self.radial_axes] + h = location.vector[self._axis] + top_h = .5*self._depth + bot_h = -.5*self._depth + # --- Compute distances --- + radial_outward = normalize(r, epsilon=1e-5) + surf_r = radial_outward * self._radius + radial_dist2 = vec_squared(r) + inside_cyl = radial_dist2 <= self._radius**2 + clamped_r = where(inside_cyl, r, surf_r) + # --- Closest point on bottom / top --- + above = h >= 0 + flat_h = where(above, top_h, bot_h) + on_flat = ccat([flat_h, clamped_r], self._center.shape['vector']) + normal_flat = where(above, self.up, -self.up) + # --- Closest point on cylinder --- + clamped_h = clip(h, bot_h, top_h) + on_cyl = ccat([surf_r, clamped_h], self._center.shape['vector']) + normal_cyl = ccat([radial_outward, 0], self._center.shape['vector'], expand_values=True) + # --- Choose closest --- + d_flat = length(on_flat - location) + d_cyl = length(on_cyl - location) + flat_closer = d_flat <= d_cyl + surf_point = where(flat_closer, on_flat, on_cyl) + inside = inside_cyl & (h >= bot_h) & (h <= top_h) + sgn_dist = minimum(d_flat, d_cyl) * where(inside, -1, 1) + delta = surf_point - location + normal = where(flat_closer, normal_flat, normal_cyl) + delta = rotate_vector(delta, self._rotation) + normal = rotate_vector(normal, self._rotation) + if instance(self): + sgn_dist, delta, normal = math.at_min((sgn_dist, delta, normal), key=sgn_dist, dim=instance(self)) + return sgn_dist, delta, normal, None, None + + def sample_uniform(self, *shape: math.Shape): + raise NotImplementedError + + def bounding_radius(self): + return math.length(vec(rad=self._radius, dep=.5*self._depth)) + + def bounding_half_extent(self): + if self._rotation is not None: + return expand(self.bounding_radius(), self._center.shape.only('vector')) + return ccat([.5*self._depth, expand(self._radius, channel(vector=self.radial_axes))], self._center.shape['vector']) + + def at(self, center: Tensor) -> 'Geometry': + return Cylinder(center, self._radius, self._depth, self._rotation, self._axis, self._variables) + + def rotated(self, angle): + if self._rotation is None: + return Cylinder(self._center, self._radius, self._depth, angle, self._axis, self._variables) + else: + matrix = self._rotation @ (angle if dual(angle) else math.rotation_matrix(angle)) + return Cylinder(self._center, self._radius, self._depth, matrix, self._axis, self._variables) + + def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': + return Cylinder(self._center, self._radius * factor, self._depth * factor, self._rotation, self._axis, self._variables) + + def __getitem__(self, item): + item = slicing_dict(self, item) + return Cylinder(self._center[_keep_vector(item)], self._radius[item], self._depth[item], math.slice(self._rotation, item), self._axis, self._variables) + + @staticmethod + def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': + if all(isinstance(v, Cylinder) for v in values) and all(v._axis == values[0]._axis for v in values): + variables = set() + variables.update(*[set(v._variables) for v in values]) + if any(v._rotation is not None for v in values): + matrices = [v._rotation for v in values] + if any(m is None for m in matrices): + any_angle = math.rotation_angles([m for m in matrices if m is not None][0]) + unit_matrix = math.rotation_matrix(any_angle * 0) + matrices = [unit_matrix if m is None else m for m in matrices] + rotation = stack(matrices, dim, **kwargs) + else: + rotation = None + center = stack([v.center for v in values], dim, simplify=True, **kwargs) + radius = stack([v.radius for v in values], dim, simplify=True, **kwargs) + depth = stack([v.depth for v in values], dim, simplify=True, **kwargs) + return Cylinder(center, radius, depth, rotation, values[0]._axis, variables) + else: + return Geometry.__stack__(values, dim, **kwargs) + + @property + def faces(self) -> 'Geometry': + raise NotImplementedError(f"Cylinder.faces not implemented.") + + @property + def face_centers(self) -> Tensor: + raise NotImplementedError + + @property + def face_areas(self) -> Tensor: + raise NotImplementedError + + @property + def face_normals(self) -> Tensor: + raise NotImplementedError + + @property + def boundary_elements(self) -> Dict[str, Tuple[Dict[str, slice], Dict[str, slice]]]: + return {} + + @property + def boundary_faces(self) -> Dict[str, Tuple[Dict[str, slice], Dict[str, slice]]]: + return {} + + @property + def face_shape(self) -> Shape: + return self.shape.without('vector') & dual(shell='bottom,top,lateral') + + @property + def corners(self) -> Tensor: + return math.zeros(self.shape & dual(corners=0)) From 041ccb0a2e3c2aa1f653bf6225b639e02cae7c9d Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 18 Oct 2024 17:30:13 +0200 Subject: [PATCH 15/71] [geom] Minor refactoring --- phi/geom/_box.py | 3 +-- phi/geom/_geom_ops.py | 5 +---- phi/geom/_sphere.py | 24 +++++++++++------------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/phi/geom/_box.py b/phi/geom/_box.py index 27602290b..9a0fd3229 100644 --- a/phi/geom/_box.py +++ b/phi/geom/_box.py @@ -450,8 +450,7 @@ def __repr__(self): def __getitem__(self, item) -> 'Cuboid': item = _keep_vector(slicing_dict(self, item)) - rotation = self._rotation_matrix[item] if self._rotation_matrix is not None else None - return Cuboid(self._center[item], self._half_size[item], rotation, size_variable=self._size_variable) + return Cuboid(self._center[item], self._half_size[item], math.slice(self._rotation_matrix, item), size_variable=self._size_variable) @staticmethod def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': diff --git a/phi/geom/_geom_ops.py b/phi/geom/_geom_ops.py index 97918e7c0..e00fd71d4 100644 --- a/phi/geom/_geom_ops.py +++ b/phi/geom/_geom_ops.py @@ -285,10 +285,7 @@ def _stack_geometries(geometries: Sequence[Geometry], set_op: str, dim=None) -> elif len(geometries) == 1: return geometries[0] elif set_op == 'union' and all(type(g) == type(geometries[0]) and isinstance(g, PhiTreeNode) for g in geometries): - # ToDo look into using stacked attributes for intersection - attrs = variable_attributes(geometries[0]) - values = {a: math.stack([getattr(g, a) for g in geometries], dim) for a in attrs} - return copy_with(geometries[0], **values) + return math.stack(tuple(geometries), dim, simplify=True) else: geos = math.layout(geometries, dim) return GeometryStack(geos, set_op=set_op) diff --git a/phi/geom/_sphere.py b/phi/geom/_sphere.py index dc9d96e0a..ff623c5b3 100644 --- a/phi/geom/_sphere.py +++ b/phi/geom/_sphere.py @@ -23,6 +23,7 @@ def __init__(self, Args: center: Sphere center as `Tensor` with `vector` dimension. The spatial dimension order should be specified in the `vector` dimension via item names. + Can be left empty to specify dimensions via kwargs. radius: Sphere radius as `float` or `Tensor` **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. """ @@ -41,6 +42,15 @@ def __init__(self, self._radius_variable = radius_variable assert 'vector' not in self._radius.shape, f"Sphere radius must not vary along vector but got {radius}" + def __all_attrs__(self) -> tuple: + return ('_center', '_radius') + + def __variable_attrs__(self) -> tuple: + return ('_center', '_radius') if self._radius_variable else ('_center',) + + def __value_attrs__(self) -> tuple: + return () + @property def shape(self): if self._center is None or self._radius is None: @@ -109,7 +119,7 @@ def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, center_dist = math.vec_length(center_delta) sgn_dist = center_dist - self_radius if instance(self): - self_center, self_radius, sgn_dist, center_delta, center_dist = math.at_min((self.center, self.radius, sgn_dist, center_delta, center_dist), key=abs(sgn_dist), dim=instance) + self_center, self_radius, sgn_dist, center_delta, center_dist = math.at_min((self.center, self.radius, sgn_dist, center_delta, center_dist), key=abs(sgn_dist), dim=instance(self)) normal = math.safe_div(center_delta, center_dist) default_normal = wrap([1] + [0] * (self.spatial_rank-1), self.shape['vector']) normal = math.where(center_dist == 0, default_normal, normal) @@ -146,18 +156,6 @@ def rotated(self, angle): def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': return Sphere(self.center, self.radius * factor, radius_variable=self._radius_variable) - def __variable_attrs__(self): - return ('_center', '_radius') if self._radius_variable else ('_center',) - - def __value_attrs__(self): - return '_center', - - def __value_attrs__(self): - return '_center', '_radius' - - def __value_attrs__(self): - return '_center', '_radius' - def __getitem__(self, item): item = slicing_dict(self, item) return Sphere(self._center[_keep_vector(item)], self._radius[item], radius_variable=self._radius_variable) From 6baf0eebef234c78bd9c661791b2f5c06277f93c Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 18 Oct 2024 13:42:07 +0200 Subject: [PATCH 16/71] [geom] SDFGrid optional surface properties --- phi/geom/_geom.py | 2 - phi/geom/_sdf.py | 15 +------ phi/geom/_sdf_grid.py | 95 +++++++++++++++++++++++++++---------------- 3 files changed, 62 insertions(+), 50 deletions(-) diff --git a/phi/geom/_geom.py b/phi/geom/_geom.py index 17f1a1d15..0235c97ad 100644 --- a/phi/geom/_geom.py +++ b/phi/geom/_geom.py @@ -490,8 +490,6 @@ def __repr__(self): def __getitem__(self, item): raise NotImplementedError - # assert isinstance(item, dict), "Index must be dict of type {dim: slice/int}." - # item = {dim: sel for dim, sel in item.items() if dim != 'vector'} # attrs = {a: getattr(self, a)[item] for a in variable_attributes(self)} # return copy_with(self, **attrs) diff --git a/phi/geom/_sdf.py b/phi/geom/_sdf.py index 6c34eadb0..5373f112d 100644 --- a/phi/geom/_sdf.py +++ b/phi/geom/_sdf.py @@ -1,9 +1,8 @@ from typing import Union, Tuple, Dict, Any, Callable from phiml import math -from phiml.math import Shape, Tensor, spatial, channel, instance +from phiml.math import Shape, Tensor, channel, instance from phiml.math.magic import slicing_dict -from . import UniformGrid from ._box import BaseBox from ._geom import Geometry @@ -68,18 +67,6 @@ def bounds(self) -> BaseBox: def size(self): return self._bounds.size - @property - def resolution(self): - return spatial(self._sdf) - - @property - def points(self): - return UniformGrid(spatial(self._sdf), self._bounds).center - - @property - def grid(self): - return UniformGrid(spatial(self._sdf), self._bounds) - @property def center(self) -> Tensor: return self._center diff --git a/phi/geom/_sdf_grid.py b/phi/geom/_sdf_grid.py index 740ad98ef..c1f2c5cf1 100644 --- a/phi/geom/_sdf_grid.py +++ b/phi/geom/_sdf_grid.py @@ -3,6 +3,7 @@ from phiml import math from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch +from phiml.math.magic import slicing_dict from . import UniformGrid from ._geom import Geometry from ._box import Box, BaseBox, Cuboid @@ -12,7 +13,13 @@ class SDFGrid(Geometry): """ Grid-based signed distance field. """ - def __init__(self, sdf: Tensor, bounds: BaseBox, approximate_outside=True, gradient: Tensor = None, center: Tensor = None, volume: Tensor = None, bounding_radius: Tensor = None): + def __init__(self, + sdf: Tensor, + bounds: BaseBox, + approximate_outside=True, + gradient: Tensor = None, + to_surface: Tensor = None, surf_normal: Tensor = None, surf_index: Tensor = None, + center: Tensor = None, volume: Tensor = None, bounding_radius: Tensor = None): """ Args: sdf: Signed distance values. `Tensor` with spatial dimensions corresponding to the physical space. @@ -29,11 +36,14 @@ def __init__(self, sdf: Tensor, bounds: BaseBox, approximate_outside=True, gradi self._bounds = bounds self._approximate_outside = approximate_outside dx = bounds.size / spatial(sdf) - if gradient is not None: - self._grad = gradient - else: + if gradient is True: grad = math.spatial_gradient(sdf, dx=dx, difference='forward', padding=math.extrapolation.ZERO_GRADIENT, stack_dim=channel('vector')) self._grad = grad[{dim: slice(0, -1) for dim in spatial(sdf).names}] + else: + self._grad = gradient + self._to_surface = to_surface + self._surf_normal = surf_normal + self._surf_index = surf_index if center is not None: self._center = center else: @@ -52,6 +62,12 @@ def __init__(self, sdf: Tensor, bounds: BaseBox, approximate_outside=True, gradi dist = math.where(self._sdf <= 0, dist, 0) self._bounding_radius = math.max(dist) + def __variable_attrs__(self): + return '_sdf', '_bounds', '_grad', '_to_surface', '_surf_normal', '_surf_index', '_center', '_volume', '_bounding_radius' + + def __value_attrs__(self): + return '_sdf', + @property def values(self): """Signed distance grid.""" @@ -81,6 +97,10 @@ def dx(self): def points(self): return UniformGrid(spatial(self._sdf), self._bounds).center + @property + def grid(self): + return UniformGrid(spatial(self._sdf), self._bounds) + @property def center(self) -> Tensor: return self._center @@ -93,12 +113,6 @@ def shape(self) -> Shape: def volume(self) -> Tensor: return self._volume - def __variable_attrs__(self): - return '_sdf', '_bounds', '_grad', '_center', '_volume', '_bounding_radius' - - def __value_attrs__(self): - return '_sdf', - @property def faces(self) -> 'Geometry': raise NotImplementedError(f"SDF does not support faces") @@ -141,26 +155,29 @@ def lies_inside(self, location: Tensor) -> Tensor: return sdf_val <= 0 def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: - float_idx = (location - self._bounds.lower) / self.size * self.resolution - sdf_val = math.grid_sample(self._sdf, float_idx - .5, math.extrapolation.ZERO_GRADIENT) - sdf_grad = math.grid_sample(self._grad, float_idx - 1, math.extrapolation.ZERO_GRADIENT) - sdf_grad = math.vec_normalize(sdf_grad) # theoretically not necessary - sgn_dist = sdf_val if self._approximate_outside: - within_bounds = self._bounds.lies_inside(location) - from_center = location - self._center - dist_from_center = math.vec_length(from_center) - self._bounding_radius - sgn_dist = math.where(within_bounds, sdf_val, dist_from_center) - sdf_grad = math.where(within_bounds, sdf_grad, math.vec_normalize(from_center)) - delta = sgn_dist * -sdf_grad - surface_pos = location + delta - surf_float_idx = (surface_pos - self._bounds.lower) / self.size * self.resolution - normal = math.grid_sample(self._grad, surf_float_idx - 1, math.extrapolation.ZERO_GRADIENT) - normal = math.where(self._bounds.lies_inside(surface_pos), normal, sdf_grad) # use current normal if surface point is outside SDF grid - normal = math.vec_normalize(normal) - face_index = expand(0, non_channel(location)) + location = self._bounds.push(location, outward=False) + float_idx = (location - self._bounds.lower) / self.size * self.resolution + sgn_dist = math.grid_sample(self._sdf, float_idx - .5, math.extrapolation.ZERO_GRADIENT) + if self._to_surface is not None: + to_surf = math.grid_sample(self._to_surface, float_idx - .5, math.extrapolation.ZERO_GRADIENT) + else: + sdf_grad = math.grid_sample(self._grad, float_idx - 1, math.extrapolation.ZERO_GRADIENT) + sdf_grad = math.vec_normalize(sdf_grad) # theoretically not necessary + to_surf = sgn_dist * -sdf_grad + surface_pos = location + to_surf + if self._surf_normal is not None: + normal = math.grid_sample(self._surf_normal, float_idx - .5, math.extrapolation.ZERO_GRADIENT) + int_index = math.to_int32(float_idx) + face_index = self._surf_index[int_index] + else: + surf_float_idx = (surface_pos - self._bounds.lower) / self.size * self.resolution + normal = math.grid_sample(self._grad, surf_float_idx - 1, math.extrapolation.ZERO_GRADIENT) + # normal = math.where(self._bounds.lies_inside(surface_pos), normal, sdf_grad) # use current normal if surface point is outside SDF grid + normal = math.vec_normalize(normal) + face_index = None offset = normal.vector @ surface_pos.vector - return sgn_dist, delta, normal, offset, face_index + return sgn_dist, to_surf, normal, offset, face_index def approximate_signed_distance(self, location: Tensor) -> Tensor: float_idx = (location - self._bounds.lower) / self.size * self.resolution @@ -182,7 +199,7 @@ def bounding_half_extent(self) -> Tensor: return self._bounds.half_size # this could be too small if the center is not in the middle of the bounds def shifted(self, delta: Tensor) -> 'Geometry': - return SDFGrid(self._sdf, self._bounds.shifted(delta), self._approximate_outside, self._grad, self._center + delta, self._volume, self._bounding_radius) + return SDFGrid(self._sdf, self._bounds.shifted(delta), self._approximate_outside, self._grad, self._to_surface, self._surf_normal, self._surf_index, self._center + delta, self._volume, self._bounding_radius) def at(self, center: Tensor) -> 'Geometry': return self.shifted(center - self._center) @@ -194,22 +211,24 @@ def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': off_center = self._center - self._bounds.center volume = self._volume * factor ** self.spatial_rank bounds = self._bounds.scaled(factor).shifted(off_center * (factor - 1)).corner_representation() - return SDFGrid(self._sdf, bounds, self._approximate_outside, self._grad, self._center, volume, self._bounding_radius * factor) + return SDFGrid(self._sdf, bounds, self._approximate_outside, self._grad, self._to_surface, self._surf_normal, self._surf_index, self._center, volume, self._bounding_radius * factor) def __getitem__(self, item): + item = slicing_dict(self, item) if 'vector' in item: raise NotImplementedError("SDF projection not yet supported") - return SDFGrid(self._sdf[item], self._bounds[item], self._approximate_outside, self._grad[item], self._center[item], self._volume[item], self._bounding_radius[item]) + return SDFGrid(self._sdf[item], self._bounds[item], self._approximate_outside, math.slice(self._grad, item), math.slice(self._to_surface, item), math.slice(self._surf_normal, item), math.slice(self._surf_index, item), math.slice(self._center, item), math.slice(self._volume, item), math.slice(self._bounding_radius, item)) def sample_sdf(geometry: Geometry, - bounds: BaseBox = None, + bounds: BaseBox | UniformGrid = None, resolution: Shape = math.EMPTY_SHAPE, approximate_outside=False, rebuild: Optional[str] = None, valid_dist=None, rel_margin=.1, abs_margin=0., + cache_surface=False, **resolution_: int) -> SDFGrid: """ Build a grid of signed distance values for a given `Geometry` object. @@ -231,8 +250,11 @@ def sample_sdf(geometry: Geometry, if bounds is None: bounds: BaseBox = geometry.bounding_box() bounds = Cuboid(bounds.center, half_size=bounds.half_size * (1 + 2 * rel_margin) + 2 * abs_margin) + elif isinstance(bounds, UniformGrid): + assert not resolution, f"When specifying a UniformGrid, separate resolution values are not allowed." + resolution = bounds.resolution + bounds = bounds.bounds points = UniformGrid(resolution, bounds).center - sdf = geometry.approximate_signed_distance(points) reduce = instance(geometry) & spatial(geometry) if reduce: center = math.mean(geometry.center, reduce) @@ -244,7 +266,12 @@ def sample_sdf(geometry: Geometry, volume = geometry.volume bounding_radius = geometry.bounding_radius() rebuild = None if rebuild == 'auto' else rebuild - approximate = SDFGrid(sdf, bounds, approximate_outside, center=center, volume=volume, bounding_radius=bounding_radius) + if cache_surface: + sdf, delta, normal, _, idx = geometry.approximate_closest_surface(points) + approximate = SDFGrid(sdf, bounds, approximate_outside, None, delta, normal, idx, center=center, volume=volume, bounding_radius=bounding_radius) + else: + sdf = geometry.approximate_signed_distance(points) + approximate = SDFGrid(sdf, bounds, approximate_outside, center=center, volume=volume, bounding_radius=bounding_radius) if rebuild is None: return approximate assert rebuild in ['from-surface'] From 03f090af112dd119c23d738c1f9dd045b73123f5 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sat, 19 Oct 2024 14:40:44 +0200 Subject: [PATCH 17/71] [geom] Refactor Cylinder as @dataclass --- phi/flow.py | 2 +- phi/geom/__init__.py | 2 +- phi/geom/_cylinder.py | 203 ++++++++++++++++++++---------------------- 3 files changed, 99 insertions(+), 108 deletions(-) diff --git a/phi/flow.py b/phi/flow.py index 6cfea35f3..040c6899b 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -22,7 +22,7 @@ # Classes from phiml.math import Shape, Tensor, DType, Solve -from .geom import Geometry, Point, Sphere, Box, Cuboid, Cylinder, UniformGrid, Mesh, Graph +from .geom import Geometry, Point, Sphere, Box, Cuboid, cylinder, UniformGrid, Mesh, Graph from .field import Field, Grid, CenteredGrid, StaggeredGrid, mask, Noise, PointCloud, Scene, resample, GeometryMask, SoftGeometryMask, HardGeometryMask from .physics.fluid import Obstacle diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index d409731cc..0b32be2f1 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -14,7 +14,7 @@ from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, rotate, sample_function from ._box import Box, BaseBox, Cuboid, bounding_box from ._sphere import Sphere -from ._cylinder import Cylinder +from ._cylinder import Cylinder, cylinder from ._grid import UniformGrid, enclosing_grid from ._graph import Graph, graph from ._mesh import Mesh, mesh, load_su2, load_gmsh, load_stl, mesh_from_numpy, build_mesh diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index 665af8618..d76dc3593 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -1,13 +1,16 @@ +from dataclasses import dataclass +from functools import cached_property from typing import Union, Dict, Tuple, Optional, Sequence from phiml import math -from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, \ - maximum +from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum +from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector from ._sphere import Sphere +@dataclass(frozen=True) class Cylinder(Geometry): """ N-dimensional cylinder. @@ -16,127 +19,73 @@ class Cylinder(Geometry): For cylinders whose bottom and top lie outside the domain or are otherwise not needed, you may use `infinite_cylinder` instead, which simplifies computations. """ - def __init__(self, - center: Tensor = None, - radius: Union[float, Tensor] = None, - depth: Union[float, Tensor] = None, - rotation: Optional[Tensor] = None, - axis=-1, - variables=('center', 'radius', 'depth', 'rotation'), - **center_: Union[float, Tensor]): - """ - Args: - center: Cylinder center as `Tensor` with `vector` dimension. - The spatial dimension order should be specified in the `vector` dimension via item names. - Can be left empty to specify dimensions via kwargs. - radius: Cylinder radius as `float` or `Tensor`. - depth: Cylinder length as `float` or `Tensor`. - rotation: Rotation angle(s) or rotation matrix. - axis: The cylinder is aligned along this axis, perturbed by `rotation`. - variables: Which properties of the cylinder are variable, i.e. traced and optimizable. All by default. - **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. - """ - if center is not None: - assert isinstance(center, Tensor), f"center must be a Tensor but got {type(center).__name__}" - assert 'vector' in center.shape, f"Sphere center must have a 'vector' dimension." - assert center.shape.get_item_names('vector') is not None, f"Vector dimension must list spatial dimensions as item names. Use the syntax Sphere(x=x, y=y) to assign names." - self._center = center - else: - self._center = wrap(tuple(center_.values()), channel(vector=tuple(center_.keys()))) - self._radius = wrap(radius) - self._depth = wrap(depth) - self._rotation = None if rotation is None else rotation_matrix(rotation) - self._variables = tuple([v if v.startswith('_') else '_' + v for v in variables]) - self._axis = self._center.vector.item_names[axis] if isinstance(axis, int) else axis - assert 'vector' not in self._radius.shape, f"Cylinder radius must not vary along vector but got {radius}" - assert set(self._variables).issubset(set(self.__all_attrs__())), f"Invalid variables: {self._variables}" - assert self._axis in self._center.vector.item_names, f"Cylinder axis {self._axis} not part of vector dim {self._center.vector}" - - def __all_attrs__(self) -> tuple: - return '_center', '_radius', '_depth', '_rotation' - - def __variable_attrs__(self) -> tuple: - return self._variables + _center: Tensor + radius: Tensor + depth: Tensor + rotation: Tensor # rotation matrix + axis: str - def __value_attrs__(self) -> tuple: - return () - - @property - def shape(self) -> Shape: - if self._center is None or self._radius is None or self._depth is None: - raise RuntimeError - return self._center.shape & self._radius.shape & self._depth.shape - - @property - def radius(self) -> Tensor: - return self._radius + variables: Tuple[str, ...] = ('_center', 'radius', 'depth', 'rotation') + values: Tuple[str, ...] = () @property def center(self) -> Tensor: return self._center - @property - def depth(self) -> Tensor: - return self._depth - - @property - def axis(self) -> str: - return self._axis + @cached_property + def shape(self) -> Shape: + return self._center.shape & self.radius.shape & self.depth.shape - @property + @cached_property def radial_axes(self) -> Sequence[str]: - return [d for d in self._center.vector.item_names if d != self._axis] - - @property - def rotation_matrix(self): - return self._rotation + return [d for d in self._center.vector.item_names if d != self.axis] - @property + @cached_property def volume(self) -> math.Tensor: - return Sphere.volume_from_radius(self._radius, self.spatial_rank - 1) * self._depth + return Sphere.volume_from_radius(self.radius, self.spatial_rank - 1) * self.depth - @property + @cached_property def up(self): - return math.rotate_vector(vec(**{d: 1 if d == self._axis else 0 for d in self._center.vector.item_names}), self._rotation) + return math.rotate_vector(vec(**{d: 1 if d == self.axis else 0 for d in self._center.vector.item_names}), self.rotation) def lies_inside(self, location): - pos = rotate_vector(location - self._center, self._rotation, invert=True) + pos = rotate_vector(location - self._center, self.rotation, invert=True) r = pos.vector[self.radial_axes] - h = pos.vector[self._axis] - inside = (vec_squared(r) <= self._radius**2) & (h >= -.5*self._depth) & (h <= .5*self._depth) + h = pos.vector[self.axis] + inside = (vec_squared(r) <= self.radius**2) & (h >= -.5*self.depth) & (h <= .5*self.depth) return math.any(inside, instance(self)) # union for instance dimensions def approximate_signed_distance(self, location: Union[Tensor, tuple]): - location = math.rotate_vector(location - self._center, self._rotation, invert=True) + location = math.rotate_vector(location - self._center, self.rotation, invert=True) r = location.vector[self.radial_axes] - h = location.vector[self._axis] - top_h = .5*self._depth - bot_h = -.5*self._depth + h = location.vector[self.axis] + top_h = .5*self.depth + bot_h = -.5*self.depth # --- Compute distances --- radial_outward = normalize(r, epsilon=1e-5) - surf_r = radial_outward * self._radius + surf_r = radial_outward * self.radius radial_dist2 = vec_squared(r) - inside_cyl = radial_dist2 <= self._radius**2 + inside_cyl = radial_dist2 <= self.radius**2 clamped_r = where(inside_cyl, r, surf_r) # --- Closest point on bottom / top --- sgn_dist_side = abs(h) - top_h # --- Closest point on cylinder --- - sgn_dist_cyl = length(r) - self._radius + sgn_dist_cyl = length(r) - self.radius # inside (all <= 0) -> largest SDF, outside (any > 0) -> largest positive SDF sgn_dist = maximum(sgn_dist_cyl, sgn_dist_side) return math.min(sgn_dist, instance(self)) def approximate_closest_surface(self, location: Tensor): - location = math.rotate_vector(location - self._center, self._rotation, invert=True) + location = math.rotate_vector(location - self._center, self.rotation, invert=True) r = location.vector[self.radial_axes] - h = location.vector[self._axis] - top_h = .5*self._depth - bot_h = -.5*self._depth + h = location.vector[self.axis] + top_h = .5*self.depth + bot_h = -.5*self.depth # --- Compute distances --- radial_outward = normalize(r, epsilon=1e-5) - surf_r = radial_outward * self._radius + surf_r = radial_outward * self.radius radial_dist2 = vec_squared(r) - inside_cyl = radial_dist2 <= self._radius**2 + inside_cyl = radial_dist2 <= self.radius**2 clamped_r = where(inside_cyl, r, surf_r) # --- Closest point on bottom / top --- above = h >= 0 @@ -156,8 +105,8 @@ def approximate_closest_surface(self, location: Tensor): sgn_dist = minimum(d_flat, d_cyl) * where(inside, -1, 1) delta = surf_point - location normal = where(flat_closer, normal_flat, normal_cyl) - delta = rotate_vector(delta, self._rotation) - normal = rotate_vector(normal, self._rotation) + delta = rotate_vector(delta, self.rotation) + normal = rotate_vector(normal, self.rotation) if instance(self): sgn_dist, delta, normal = math.at_min((sgn_dist, delta, normal), key=sgn_dist, dim=instance(self)) return sgn_dist, delta, normal, None, None @@ -166,37 +115,39 @@ def sample_uniform(self, *shape: math.Shape): raise NotImplementedError def bounding_radius(self): - return math.length(vec(rad=self._radius, dep=.5*self._depth)) + return math.length(vec(rad=self.radius, dep=.5*self.depth)) def bounding_half_extent(self): - if self._rotation is not None: + if self.rotation is not None: return expand(self.bounding_radius(), self._center.shape.only('vector')) - return ccat([.5*self._depth, expand(self._radius, channel(vector=self.radial_axes))], self._center.shape['vector']) + return ccat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) def at(self, center: Tensor) -> 'Geometry': - return Cylinder(center, self._radius, self._depth, self._rotation, self._axis, self._variables) + return Cylinder(center, self.radius, self.depth, self.rotation, self.axis, self.variables, self.values) def rotated(self, angle): - if self._rotation is None: - return Cylinder(self._center, self._radius, self._depth, angle, self._axis, self._variables) + if self.rotation is None: + return Cylinder(self._center, self.radius, self.depth, angle, self.axis, self.variables, self.values) else: - matrix = self._rotation @ (angle if dual(angle) else math.rotation_matrix(angle)) - return Cylinder(self._center, self._radius, self._depth, matrix, self._axis, self._variables) + matrix = self.rotation @ (angle if dual(angle) else math.rotation_matrix(angle)) + return Cylinder(self._center, self.radius, self.depth, matrix, self.axis, self.variables, self.values) def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': - return Cylinder(self._center, self._radius * factor, self._depth * factor, self._rotation, self._axis, self._variables) + return Cylinder(self._center, self.radius * factor, self.depth * factor, self.rotation, self.axis, self.variables, self.values) def __getitem__(self, item): item = slicing_dict(self, item) - return Cylinder(self._center[_keep_vector(item)], self._radius[item], self._depth[item], math.slice(self._rotation, item), self._axis, self._variables) + return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self._variables, self.values) @staticmethod def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': - if all(isinstance(v, Cylinder) for v in values) and all(v._axis == values[0]._axis for v in values): - variables = set() - variables.update(*[set(v._variables) for v in values]) - if any(v._rotation is not None for v in values): - matrices = [v._rotation for v in values] + if all(isinstance(v, Cylinder) for v in values) and all(v.axis == values[0].axis for v in values): + var_attrs = set() + var_attrs.update(*[set(v.variables) for v in values]) + val_attrs = set() + val_attrs.update(*[set(v.values) for v in values]) + if any(v.rotation is not None for v in values): + matrices = [v.rotation for v in values] if any(m is None for m in matrices): any_angle = math.rotation_angles([m for m in matrices if m is not None][0]) unit_matrix = math.rotation_matrix(any_angle * 0) @@ -207,7 +158,7 @@ def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': center = stack([v.center for v in values], dim, simplify=True, **kwargs) radius = stack([v.radius for v in values], dim, simplify=True, **kwargs) depth = stack([v.depth for v in values], dim, simplify=True, **kwargs) - return Cylinder(center, radius, depth, rotation, values[0]._axis, variables) + return Cylinder(center, radius, depth, rotation, values[0].axis, tuple(var_attrs), tuple(val_attrs)) else: return Geometry.__stack__(values, dim, **kwargs) @@ -242,3 +193,43 @@ def face_shape(self) -> Shape: @property def corners(self) -> Tensor: return math.zeros(self.shape & dual(corners=0)) + + def __eq__(self, other): + return Geometry.__eq__(self, other) + + +def cylinder(center: Tensor = None, + radius: Union[float, Tensor] = None, + depth: Union[float, Tensor] = None, + rotation: Optional[Tensor] = None, + axis=-1, + variables=('center', 'radius', 'depth', 'rotation'), + **center_: Union[float, Tensor]): + """ + Args: + center: Cylinder center as `Tensor` with `vector` dimension. + The spatial dimension order should be specified in the `vector` dimension via item names. + Can be left empty to specify dimensions via kwargs. + radius: Cylinder radius as `float` or `Tensor`. + depth: Cylinder length as `float` or `Tensor`. + rotation: Rotation angle(s) or rotation matrix. + axis: The cylinder is aligned along this axis, perturbed by `rotation`. + variables: Which properties of the cylinder are variable, i.e. traced and optimizable. All by default. + **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. + """ + if center is not None: + assert isinstance(center, Tensor), f"center must be a Tensor but got {type(center).__name__}" + assert 'vector' in center.shape, f"Sphere center must have a 'vector' dimension." + assert center.shape.get_item_names('vector') is not None, f"Vector dimension must list spatial dimensions as item names. Use the syntax Sphere(x=x, y=y) to assign names." + center = center + else: + center = wrap(tuple(center_.values()), channel(vector=tuple(center_.keys()))) + radius = wrap(radius) + depth = wrap(depth) + rotation = rotation_matrix(rotation) + axis = center.vector.item_names[axis] if isinstance(axis, int) else axis + variables = [{'center': '_center'}.get(v, v) for v in variables] + assert 'vector' not in radius.shape, f"Cylinder radius must not vary along vector but got {radius}" + assert set(variables).issubset(set(all_attributes(Cylinder))), f"Invalid variables: {variables}" + assert axis in center.vector.item_names, f"Cylinder axis {axis} not part of vector dim {center.vector}" + return Cylinder(center, radius, depth, rotation, axis, tuple(variables), ()) From 0a277f6f64ad0d3aaa1ddd1aa46b040d1841a9a8 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 20 Oct 2024 18:37:14 +0200 Subject: [PATCH 18/71] [vis] Avoid unnecessary stacking --- phi/vis/_vis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index 423975e4d..1e9d5f31f 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -403,7 +403,7 @@ def _space(*values: Field or Tensor, ignore_dims: Shape, log_dims: Tuple[str], e if '_' in all_dims and len(all_dims) > 2: all_dims.remove('_') all_bounds = [embed(get_default_limits(f, all_dims, log_dims, e).without(ignore_dims.names).largest(shape), all_dims) for f, e in zip(values, errs)] - bounds: Box = math.stack(all_bounds, batch('_fields')) + bounds: Box = math.stack(all_bounds, batch('_fields'), simplify=True) lower = math.finite_min(bounds.lower, bounds.shape.without('vector'), default=-math.INF) upper = math.finite_max(bounds.upper, bounds.shape.without('vector'), default=math.INF) return Box(lower, upper) From 4d3ca16b02ee7ea0c2aa7762133e54cb770d4b28 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 21 Oct 2024 15:49:52 +0200 Subject: [PATCH 19/71] fixup cylinder dataclass --- phi/geom/_cylinder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index d76dc3593..b721ff5e3 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -137,7 +137,7 @@ def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': def __getitem__(self, item): item = slicing_dict(self, item) - return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self._variables, self.values) + return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self.variables, self.values) @staticmethod def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': From 2f64194c11c3f4fe0519c86e379fd3b8dd42169c Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 21 Oct 2024 15:50:11 +0200 Subject: [PATCH 20/71] [geom] Add Cylinder.vertex_rings() --- phi/geom/_cylinder.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index b721ff5e3..d78f4992a 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -3,7 +3,7 @@ from typing import Union, Dict, Tuple, Optional, Sequence from phiml import math -from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum +from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector @@ -197,6 +197,17 @@ def corners(self) -> Tensor: def __eq__(self, other): return Geometry.__eq__(self, other) + def vertex_rings(self, count: Shape) -> Tensor: + if self.spatial_rank == 3: + angle = linspace(0, 2*PI, count) + h = stack({'bot': -.5 * self.depth, 'top': .5 * self.depth}, '~face') + s = sin(angle) * self.radius + c = cos(angle) * self.radius + r = stack([s, c], channel(vector=self.radial_axes)) + x = ccat([h, r], self._center.shape['vector'], expand_values=True) + return math.rotate_vector(x, self.rotation) + self._center + raise NotImplementedError + def cylinder(center: Tensor = None, radius: Union[float, Tensor] = None, From 4ece83fe0d8e28e3aad78350b0f160d0163a3bf2 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 21 Oct 2024 15:50:30 +0200 Subject: [PATCH 21/71] [vis] Support plotting cylinders with Plotly --- phi/vis/_dash/_plotly_plots.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index 3a3897835..d2afe7e69 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -21,7 +21,7 @@ from phiml.math import reshaped_numpy, dual, instance, non_dual, merge_shapes from phi import math, field, geom from phi.field import Field -from phi.geom import Sphere, BaseBox, Point, Box, SDF, SDFGrid +from phi.geom import Sphere, BaseBox, Point, Box, SDF, SDFGrid, Cylinder from phi.geom._geom_ops import GeometryStack from phi.math import Tensor, spatial, channel, non_channel from phi.vis._dash.colormaps import COLORMAPS @@ -425,6 +425,9 @@ def can_plot(self, data: Field, space: Box) -> bool: face_count = (v_count + 1) * (v_count * 2) / 2 # half as many tris as vertices elif isinstance(data.geometry, BaseBox): face_count = 12 + elif isinstance(data.geometry, Cylinder): + v_count = self._sphere_vertex_count(data.geometry.radius, space) + face_count = 2 + v_count else: return False face_count *= non_dual(data.geometry).without('vector').volume @@ -453,6 +456,11 @@ def plot_one_material(data, color, alpha: float): for inst in range(count): xyz = np.vstack([x[inst].ravel(), y[inst].ravel(), z[inst].ravel()]) figure.add_trace(go.Mesh3d(x=xyz[0], y=xyz[1], z=xyz[2], flatshading=False, alphahull=0, color=color, opacity=alpha), row=row, col=col) + elif isinstance(data.geometry, Cylinder): + vertex_count = self._sphere_vertex_count(data.geometry.radius, space) + x, y, z = data.geometry.vertex_rings(dual(vertices=vertex_count)).numpy(['vector', instance, dual]) + for inst in range(count): + figure.add_trace(go.Mesh3d(x=x[inst], y=y[inst], z=z[inst], flatshading=False, alphahull=0, color=color, opacity=alpha), row=row, col=col) elif isinstance(data.geometry, BaseBox): cx, cy, cz = reshaped_numpy(data.geometry.corners, ['vector', instance, *['~' + d for d in dims]]) x = cx.flatten() @@ -515,8 +523,8 @@ def plot(self, data: Field, figure: go.Figure, subplot, space: Box, min_val: flo symbol = None marker_size = 4 / (size[1] * (domain_y[1] - domain_y[0]) / (yrange[1] - yrange[0]) * 0.5) else: - symbol = 'asterisk' - marker_size = data.geometry[idx].bounding_radius().numpy() + symbol = 'diamond-open' + marker_size = 20 marker_size *= size[1] * (domain_y[1] - domain_y[0]) / (yrange[1] - yrange[0]) * 0.5 marker = graph_objects.scatter3d.Marker(size=marker_size, color=color_i, colorscale='Viridis', sizemode='diameter', symbol=symbol) figure.add_scatter3d(mode='markers', x=x, y=y, z=z, marker=marker, row=row, col=col) From 12bc8f178c3af83e6007bb17f6ab134f4a587e80 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 21 Oct 2024 15:50:58 +0200 Subject: [PATCH 22/71] =?UTF-8?q?[=CE=A6]=20Add=20mean,=20where,=20nonzero?= =?UTF-8?q?=20to=20convenience=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phi/flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phi/flow.py b/phi/flow.py index 040c6899b..cf31e7147 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -37,9 +37,9 @@ non_spatial, non_channel, non_batch, non_instance, non_dual, non_primal, # Shape functions (magic) unstack, stack, concat, expand, rename_dims, pack_dims, unpack_dim, flatten, cast, # Magic Ops b2i, c2b, c2d, i2b, s2b, si2d, d2i, d2s, map_s2b, map_i2b, map_c2b, map_d2c, # dim type conversions - sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, + mean, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, - scatter, gather, + scatter, gather, where, nonzero, rotate_vector as rotate, cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math safe_div, length, is_finite, is_nan, is_inf, # Basic functions jit_compile, jit_compile_linear, minimize, gradient as functional_gradient, gradient, solve_linear, solve_nonlinear, iterate, identity, # jacobian, hessian, custom_gradient # Functional magic From 4260399e7c0c38b89e583bf805a7f8fde931dabd Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 23 Oct 2024 12:29:29 +0200 Subject: [PATCH 23/71] [vis] Fix plot when default backend is not NumPy --- phi/vis/_vis.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index 1e9d5f31f..2492d621c 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -427,20 +427,21 @@ def _insert_value_dim(space: Box, pos: Tuple[int, int], subplots: dict, min_val, def layout_color(content: Dict[Tuple[int, int], List[Field]], indices: Dict[Tuple[int, int], List[dict]], color: Tensor): - result = {} - for pos, fields in content.items(): - result_pos = result[pos] = [] - counter = 0 - for i, f in enumerate(fields): - idx = indices[pos][i] - if (color[idx] != None).all: # user-specified color - result_pos.append(color[idx]) - cmap = requires_color_map(f) - channels = channel(f).without('vector') - channel_colors = counter + math.range_tensor(channels) - result_pos.append(math.where(cmap, wrap('cmap'), channel_colors)) - counter += channels.volume * math.any(~cmap, shape) - return result + with math.NUMPY: + result = {} + for pos, fields in content.items(): + result_pos = result[pos] = [] + counter = 0 + for i, f in enumerate(fields): + idx = indices[pos][i] + if (color[idx] != None).all: # user-specified color + result_pos.append(color[idx]) + cmap = requires_color_map(f) + channels = channel(f).without('vector') + channel_colors = counter + math.range_tensor(channels) + result_pos.append(math.where(cmap, wrap('cmap'), channel_colors)) + counter += channels.volume * math.any(~cmap, shape) + return result def overlay(*fields: Union[Field, Tensor, Geometry]) -> Tensor: From b8995d7c78734ac34178d9be493c5a3db9f0fe7c Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 23 Oct 2024 12:30:02 +0200 Subject: [PATCH 24/71] [geom] Fix Cylinder, allow vector axis --- phi/geom/_cylinder.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index d78f4992a..e7fac9b17 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -3,7 +3,8 @@ from typing import Union, Dict, Tuple, Optional, Sequence from phiml import math -from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos +from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ + rotation_matrix_from_directions from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector @@ -25,8 +26,8 @@ class Cylinder(Geometry): rotation: Tensor # rotation matrix axis: str - variables: Tuple[str, ...] = ('_center', 'radius', 'depth', 'rotation') - values: Tuple[str, ...] = () + variable_attrs: Tuple[str, ...] = ('_center', 'radius', 'depth', 'rotation') + value_attrs: Tuple[str, ...] = () @property def center(self) -> Tensor: @@ -123,29 +124,29 @@ def bounding_half_extent(self): return ccat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) def at(self, center: Tensor) -> 'Geometry': - return Cylinder(center, self.radius, self.depth, self.rotation, self.axis, self.variables, self.values) + return Cylinder(center, self.radius, self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) def rotated(self, angle): if self.rotation is None: - return Cylinder(self._center, self.radius, self.depth, angle, self.axis, self.variables, self.values) + return Cylinder(self._center, self.radius, self.depth, angle, self.axis, self.variable_attrs, self.value_attrs) else: matrix = self.rotation @ (angle if dual(angle) else math.rotation_matrix(angle)) - return Cylinder(self._center, self.radius, self.depth, matrix, self.axis, self.variables, self.values) + return Cylinder(self._center, self.radius, self.depth, matrix, self.axis, self.variable_attrs, self.value_attrs) def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': - return Cylinder(self._center, self.radius * factor, self.depth * factor, self.rotation, self.axis, self.variables, self.values) + return Cylinder(self._center, self.radius * factor, self.depth * factor, self.rotation, self.axis, self.variable_attrs, self.value_attrs) def __getitem__(self, item): item = slicing_dict(self, item) - return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self.variables, self.values) + return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self.variable_attrs, self.value_attrs) @staticmethod def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': if all(isinstance(v, Cylinder) for v in values) and all(v.axis == values[0].axis for v in values): var_attrs = set() - var_attrs.update(*[set(v.variables) for v in values]) + var_attrs.update(*[set(v.variable_attrs) for v in values]) val_attrs = set() - val_attrs.update(*[set(v.values) for v in values]) + val_attrs.update(*[set(v.value_attrs) for v in values]) if any(v.rotation is not None for v in values): matrices = [v.rotation for v in values] if any(m is None for m in matrices): @@ -213,7 +214,7 @@ def cylinder(center: Tensor = None, radius: Union[float, Tensor] = None, depth: Union[float, Tensor] = None, rotation: Optional[Tensor] = None, - axis=-1, + axis: int | str | Tensor = -1, variables=('center', 'radius', 'depth', 'rotation'), **center_: Union[float, Tensor]): """ @@ -225,6 +226,7 @@ def cylinder(center: Tensor = None, depth: Cylinder length as `float` or `Tensor`. rotation: Rotation angle(s) or rotation matrix. axis: The cylinder is aligned along this axis, perturbed by `rotation`. + Specified either as the dim along which the cylinder is aligned or as a vector. variables: Which properties of the cylinder are variable, i.e. traced and optimizable. All by default. **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. """ @@ -234,11 +236,19 @@ def cylinder(center: Tensor = None, assert center.shape.get_item_names('vector') is not None, f"Vector dimension must list spatial dimensions as item names. Use the syntax Sphere(x=x, y=y) to assign names." center = center else: - center = wrap(tuple(center_.values()), channel(vector=tuple(center_.keys()))) + center = wrap(tuple(center_.value_attrs()), channel(vector=tuple(center_.keys()))) radius = wrap(radius) depth = wrap(depth) - rotation = rotation_matrix(rotation) axis = center.vector.item_names[axis] if isinstance(axis, int) else axis + if isinstance(axis, Tensor): # specify cylinder axis as vector + assert 'vector' in axis.shape, f"When specifying axis a Tensor, it must have a 'vector' dimension." + assert rotation is None, f"When specifying axis as a " + axis_ = center.vector.item_names[-1] + unit_vec = vec(**{d: 1 if d == axis_ else 0 for d in center.vector.item_names}) + rotation = rotation_matrix_from_directions(unit_vec, axis) + axis = axis_ + else: + rotation = rotation_matrix(rotation) variables = [{'center': '_center'}.get(v, v) for v in variables] assert 'vector' not in radius.shape, f"Cylinder radius must not vary along vector but got {radius}" assert set(variables).issubset(set(all_attributes(Cylinder))), f"Invalid variables: {variables}" From ae79d4cc8f6006c5659414431cf0ebf4aa7c40fb Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 23 Oct 2024 12:30:20 +0200 Subject: [PATCH 25/71] [geom] Add UniformGrid.position_of(), voxel_at() --- phi/geom/_grid.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/phi/geom/_grid.py b/phi/geom/_grid.py index e97c60c78..71e644cd9 100644 --- a/phi/geom/_grid.py +++ b/phi/geom/_grid.py @@ -2,6 +2,7 @@ import numpy as np +from phiml.math import rename_dims, wrap from ._box import BaseBox, Box, Cuboid from ._geom import Geometry, GeometryException from .. import math @@ -55,6 +56,17 @@ def center(self): points = self.bounds.local_to_global(local_coords) return points + def position_of(self, voxel_index: Tensor): + voxel_index = rename_dims(voxel_index, channel, 'vector') + return self._bounds.lower + (voxel_index+.5) / self.resolution * self._bounds.size + + def voxel_at(self, location: Tensor, clamp=True): + float_idx = (location - self._bounds.lower) / self._bounds.size * self.resolution + index = math.to_int32(float_idx) + if clamp: + index = math.clip(index, 0, wrap(self.resolution, channel('vector'))-1) + return index + @property def boundary_elements(self) -> Dict[Any, Dict[str, slice]]: return {} From 525deefc06d6817ef5f1fc218162a7a59cb5577c Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 23 Oct 2024 12:30:42 +0200 Subject: [PATCH 26/71] [geom] Broadcast Mesh loading functions --- phi/geom/_mesh.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index 6a40cf865..b4abe8cc2 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -457,9 +457,10 @@ def __getitem__(self, item): s = math.slice return Mesh(vertices, polygons, self._element_rank, self._boundaries, self._center[item], self._volume[item], s(self._normals, item), s(self._face_centers, item), s(self._face_normals, item), s(self._face_areas, item), s(self._face_vertices, item), - s(self._vertex_normals, item), s(self._vertex_connectivity, item), None, self._max_cell_walk) + s(self._vertex_normals, item), s(self._vertex_connectivity, item), self._element_connectivity[item], self._max_cell_walk) +@math.broadcast def load_su2(file_or_mesh: str, cell_dim=instance('cells'), face_format: str = 'csc') -> Mesh: """ Load an unstructured mesh from a `.su2` file. @@ -488,6 +489,7 @@ def load_su2(file_or_mesh: str, cell_dim=instance('cells'), face_format: str = ' return mesh_from_numpy(points, mesh.elements, boundaries, cell_dim=cell_dim, face_format=face_format) +@math.broadcast def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance('cells'), face_format: str = 'csc'): """ Load an unstructured mesh from a `.msh` file. @@ -526,6 +528,7 @@ def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance return mesh_from_numpy(points, elements, boundaries, cell_dim=cell_dim, face_format=face_format) +@math.broadcast def load_stl(file: str, face_dim=instance('faces')): import stl model = stl.mesh.Mesh.from_file(file) @@ -952,6 +955,7 @@ def save_tri_mesh(file: str, mesh: Mesh, **extra_data): np.savez(file, vertices=v, faces=f, f_dim=instance(mesh).name, vertex_dim=instance(mesh.vertices).name, vector=mesh.vector.item_names, has_extra_data=bool(extra_data), **extra_data) +@math.broadcast def load_tri_mesh(file: str, convert=False, load_extra=()) -> Mesh | Tuple[Mesh, ...]: data = np.load(file, allow_pickle=bool(load_extra)) f_dim = instance(str(data['f_dim'])) From 8312fa5eaffd805cf2ea9f78afb41add897e86c1 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 23 Oct 2024 12:31:04 +0200 Subject: [PATCH 27/71] [geom] Add SDFGrid.approximate_occupancy(), downsample2x() --- phi/geom/_sdf_grid.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/phi/geom/_sdf_grid.py b/phi/geom/_sdf_grid.py index c1f2c5cf1..60e7baf5a 100644 --- a/phi/geom/_sdf_grid.py +++ b/phi/geom/_sdf_grid.py @@ -2,7 +2,7 @@ from typing import Union, Tuple, Dict, Any, Optional, Sequence from phiml import math -from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch +from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch, dual from phiml.math.magic import slicing_dict from . import UniformGrid from ._geom import Geometry @@ -219,6 +219,27 @@ def __getitem__(self, item): raise NotImplementedError("SDF projection not yet supported") return SDFGrid(self._sdf[item], self._bounds[item], self._approximate_outside, math.slice(self._grad, item), math.slice(self._to_surface, item), math.slice(self._surf_normal, item), math.slice(self._surf_index, item), math.slice(self._center, item), math.slice(self._volume, item), math.slice(self._bounding_radius, item)) + def approximate_occupancy(self): + assert self._surf_normal is not None + unit_corners = Cuboid(half_size=.5*self.dx).corners + surf_dist = self._surf_normal.vector @ self._to_surface.vector + corner_sdf = unit_corners.vector @ self._surf_normal.vector - surf_dist + total_dist = math.sum(abs(corner_sdf), dual) + neg_dist = math.sum(math.minimum(0, corner_sdf), dual) + occ_near_surf = -neg_dist / total_dist + occ_away = self._sdf <= 0 + return math.where(abs(self._sdf) < .5*math.vec_length(self.dx), occ_near_surf, occ_away) + + def approximate_fraction_inside(self, other_geometry: 'Geometry', balance: Union[Tensor, Number] = 0.5) -> Tensor: + if other_geometry == self.grid and math.always_close(balance, .5): + return self.approximate_occupancy() + else: + return Geometry.approximate_fraction_inside(self, other_geometry, balance) + + def downsample2x(self): + s, g, t, n, i = [math.downsample2x(v) for v in (self._sdf, self._grad, self._to_surface, self._surf_normal, self._surf_index)] + return SDFGrid(s, self._bounds, self._approximate_outside, g, t, n, i, self._center, self._volume, self._bounding_radius) + def sample_sdf(geometry: Geometry, bounds: BaseBox | UniformGrid = None, From bd240ef250aeef033237bb6e63e61a59f027a6d1 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 25 Oct 2024 14:23:59 +0200 Subject: [PATCH 28/71] [field] Support PointCloud(0, directions) --- phi/field/_point_cloud.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/phi/field/_point_cloud.py b/phi/field/_point_cloud.py index ae26a29ca..fc2898120 100644 --- a/phi/field/_point_cloud.py +++ b/phi/field/_point_cloud.py @@ -3,13 +3,14 @@ from phi import math, geom from phi.geom import Geometry, Box +from phiml.math import shape from ._field import Field from ._resample import resample from ..math import Tensor, instance, Shape, dual from ..math.extrapolation import Extrapolation, ConstantExtrapolation, PERIODIC -def PointCloud(elements: Union[Tensor, Geometry], values: Any = 1., extrapolation: Union[Extrapolation, float] = 0., bounds: Box = None) -> Field: +def PointCloud(elements: Union[Tensor, Geometry, float], values: Any = 1., extrapolation: Union[Extrapolation, float] = 0., bounds: Box = None) -> Field: """ A `PointCloud` comprises: @@ -47,6 +48,9 @@ def PointCloud(elements: Union[Tensor, Geometry], values: Any = 1., extrapolatio # indices = math.stored_indices(values)[non_dual_name] # values = math.stored_values(values) # elements = elements[{non_dual_name: indices}] + if isinstance(elements, (int, float)) and elements == 0: + assert 'vector' in shape(values), f"When constructing a PointCloud from the origin 0, values must have a 'vector' dimension" + elements = values * 0 if isinstance(elements, Tensor): elements = geom.Point(elements) result = Field(elements, values, extrapolation) From 62517f4679122ec3bb89170e4984d59358496346 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sat, 26 Oct 2024 16:00:53 +0200 Subject: [PATCH 29/71] [vis] Plotly equal aspect for 3D plots --- phi/vis/_dash/_plotly_plots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index d2afe7e69..b96165b2e 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -54,7 +54,7 @@ def create_figure(self, subplot.xaxis.update(title=bounds.vector.item_names[0], range=_get_range(bounds, 0)) subplot.yaxis.update(title=bounds.vector.item_names[1], range=_get_range(bounds, 1)) subplot.zaxis.update(title=bounds.vector.item_names[2], range=_get_range(bounds, 2)) - subplot.aspectmode = 'data' + subplot.aspectmode = 'cube' fig._phi_size = size if size[0] is not None: fig.update_layout(width=size[0] * 70) From cf2100175fcadcc7b960d4c8affca69277229895 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sat, 26 Oct 2024 16:01:54 +0200 Subject: [PATCH 30/71] [geom] Add internal closest_points_on_lines() --- phi/geom/_functions.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/phi/geom/_functions.py b/phi/geom/_functions.py index 627b24b8f..f9fa6c99a 100644 --- a/phi/geom/_functions.py +++ b/phi/geom/_functions.py @@ -1,6 +1,6 @@ from typing import Sequence, Union -from phiml.math import Tensor, channel, Shape, vec_normalize, vec, sqrt, maximum, clip, vec_squared, vec_length, where, stack, dual, argmin +from phiml.math import Tensor, channel, Shape, vec_normalize, vec, sqrt, maximum, clip, vec_squared, vec_length, where, stack, dual, argmin, cross_product from phiml.math._shape import parse_dim_order # No dependence on Geometry @@ -113,3 +113,19 @@ def closest_on_line(A, B, query): t = u.vector @ v.vector / vec_squared(v) t = clip(t, 0, 1) return A + t * v + + +def closest_points_on_lines(p1, v1, p2, v2, eps=1e-10, can_be_parallel=True): + """Find the closest points between two infinite lines defined by point and direction.""" + n = cross_product(v1, v2) + n_norm = vec_normalize(n) + diff = p2 - p1 + t1 = cross_product(v2, n_norm).vector @ diff.vector + t2 = cross_product(v1, n_norm).vector @ diff.vector + c1, c2 = p1 + t1 * v1, p2 + t2 * v2 + if can_be_parallel: + is_parallel = vec_squared(n) < eps + t = (p2-p1).vector @ v1.vector # Project p2-p1 onto v1 to get the closest point on line 1 + c1 = where(is_parallel, p1 + t * v1, c1) + c2 = where(is_parallel, p2, c2) + return c1, c2 From 8ed1f27230c6c5afa25ca11d6ab7bf2b2edd3f29 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sat, 26 Oct 2024 16:02:10 +0200 Subject: [PATCH 31/71] [geom] Vectorize decimate_tri_mesh() --- phi/geom/_mesh.py | 1 + 1 file changed, 1 insertion(+) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index b4abe8cc2..fa71950ab 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -971,6 +971,7 @@ def load_tri_mesh(file: str, convert=False, load_extra=()) -> Mesh | Tuple[Mesh, return m, *extra +@math.broadcast(dims=batch) def decimate_tri_mesh(mesh: Mesh, factor=.1, target_max=10_000,): if isinstance(mesh, NoGeometry): return mesh From f08cafd91ac127bb7bfc73e245be22dd2b213344 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sat, 26 Oct 2024 18:54:33 +0200 Subject: [PATCH 32/71] [geom] Implement Cylinder.sample_uniform() --- phi/geom/_cylinder.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index e7fac9b17..92cdcee27 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -113,7 +113,10 @@ def approximate_closest_surface(self, location: Tensor): return sgn_dist, delta, normal, None, None def sample_uniform(self, *shape: math.Shape): - raise NotImplementedError + r = Sphere(self._center[self.radial_axes], self.radius).sample_uniform(*shape) + h = math.random_uniform(*shape, -.5*self.depth, .5*self.depth) + rh = ccat([r, h], self._center.shape['vector']) + return rotate_vector(rh, self.rotation) def bounding_radius(self): return math.length(vec(rad=self.radius, dep=.5*self.depth)) @@ -127,11 +130,8 @@ def at(self, center: Tensor) -> 'Geometry': return Cylinder(center, self.radius, self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) def rotated(self, angle): - if self.rotation is None: - return Cylinder(self._center, self.radius, self.depth, angle, self.axis, self.variable_attrs, self.value_attrs) - else: - matrix = self.rotation @ (angle if dual(angle) else math.rotation_matrix(angle)) - return Cylinder(self._center, self.radius, self.depth, matrix, self.axis, self.variable_attrs, self.value_attrs) + rot = self.rotation @ rotation_matrix(angle) if self.rotation is not None else rotation_matrix(angle) + return Cylinder(self.center, self.radius, self.depth, rot, self.axis, self.variable_attrs, self.value_attrs) def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': return Cylinder(self._center, self.radius * factor, self.depth * factor, self.rotation, self.axis, self.variable_attrs, self.value_attrs) From 0505bf70ee55119b6fdf14a8e62e023a9ac9c2d6 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:05:05 +0100 Subject: [PATCH 33/71] [geom] Support cylinder(0, depth=None) --- phi/geom/_cylinder.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index 92cdcee27..d1919e186 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -210,7 +210,7 @@ def vertex_rings(self, count: Shape) -> Tensor: raise NotImplementedError -def cylinder(center: Tensor = None, +def cylinder(center: Union[Tensor, float] = None, radius: Union[float, Tensor] = None, depth: Union[float, Tensor] = None, rotation: Optional[Tensor] = None, @@ -231,6 +231,9 @@ def cylinder(center: Tensor = None, **center_: Specifies center when the `center` argument is not given. Center position by dimension, e.g. `x=0.5, y=0.2`. """ if center is not None: + if not isinstance(center, Tensor): + assert center == 0 and isinstance(axis, Tensor) + center = expand(0, axis.shape['vector']) assert isinstance(center, Tensor), f"center must be a Tensor but got {type(center).__name__}" assert 'vector' in center.shape, f"Sphere center must have a 'vector' dimension." assert center.shape.get_item_names('vector') is not None, f"Vector dimension must list spatial dimensions as item names. Use the syntax Sphere(x=x, y=y) to assign names." @@ -238,7 +241,11 @@ def cylinder(center: Tensor = None, else: center = wrap(tuple(center_.value_attrs()), channel(vector=tuple(center_.keys()))) radius = wrap(radius) - depth = wrap(depth) + if depth is None: + assert isinstance(axis, Tensor) + depth = 2 * length(axis, 'vector') + else: + depth = wrap(depth) axis = center.vector.item_names[axis] if isinstance(axis, int) else axis if isinstance(axis, Tensor): # specify cylinder axis as vector assert 'vector' in axis.shape, f"When specifying axis a Tensor, it must have a 'vector' dimension." From 0571c9f5263ee951bc99a9d573d0f0a1cfc93c1f Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:05:24 +0100 Subject: [PATCH 34/71] [geom] Support cylinders with channel dims --- phi/geom/_cylinder.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index d1919e186..2f60d358e 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -63,7 +63,7 @@ def approximate_signed_distance(self, location: Union[Tensor, tuple]): top_h = .5*self.depth bot_h = -.5*self.depth # --- Compute distances --- - radial_outward = normalize(r, epsilon=1e-5) + radial_outward = normalize(r, 'vector', epsilon=1e-5) surf_r = radial_outward * self.radius radial_dist2 = vec_squared(r) inside_cyl = radial_dist2 <= self.radius**2 @@ -71,7 +71,7 @@ def approximate_signed_distance(self, location: Union[Tensor, tuple]): # --- Closest point on bottom / top --- sgn_dist_side = abs(h) - top_h # --- Closest point on cylinder --- - sgn_dist_cyl = length(r) - self.radius + sgn_dist_cyl = length(r, 'vector') - self.radius # inside (all <= 0) -> largest SDF, outside (any > 0) -> largest positive SDF sgn_dist = maximum(sgn_dist_cyl, sgn_dist_side) return math.min(sgn_dist, instance(self)) @@ -83,7 +83,7 @@ def approximate_closest_surface(self, location: Tensor): top_h = .5*self.depth bot_h = -.5*self.depth # --- Compute distances --- - radial_outward = normalize(r, epsilon=1e-5) + radial_outward = normalize(r, 'vector', epsilon=1e-5) surf_r = radial_outward * self.radius radial_dist2 = vec_squared(r) inside_cyl = radial_dist2 <= self.radius**2 @@ -98,8 +98,8 @@ def approximate_closest_surface(self, location: Tensor): on_cyl = ccat([surf_r, clamped_h], self._center.shape['vector']) normal_cyl = ccat([radial_outward, 0], self._center.shape['vector'], expand_values=True) # --- Choose closest --- - d_flat = length(on_flat - location) - d_cyl = length(on_cyl - location) + d_flat = length(on_flat - location, 'vector') + d_cyl = length(on_cyl - location, 'vector') flat_closer = d_flat <= d_cyl surf_point = where(flat_closer, on_flat, on_cyl) inside = inside_cyl & (h >= bot_h) & (h <= top_h) @@ -119,7 +119,7 @@ def sample_uniform(self, *shape: math.Shape): return rotate_vector(rh, self.rotation) def bounding_radius(self): - return math.length(vec(rad=self.radius, dep=.5*self.depth)) + return length(vec(rad=self.radius, dep=.5*self.depth), 'vector') def bounding_half_extent(self): if self.rotation is not None: From d86ffdd0861897b22e364a2939312483e9b0457d Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:19:29 +0100 Subject: [PATCH 35/71] [geom] Implement Cylinder.bounding_half_extent for rotated cylinders --- phi/geom/_cylinder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index 2f60d358e..ef89c949c 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -4,7 +4,7 @@ from phiml import math from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ - rotation_matrix_from_directions + rotation_matrix_from_directions, sqrt from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector @@ -123,7 +123,8 @@ def bounding_radius(self): def bounding_half_extent(self): if self.rotation is not None: - return expand(self.bounding_radius(), self._center.shape.only('vector')) + tip = abs(self.up) * .5 * self.depth + return tip + self.radius * sqrt(1 - self.up**2) return ccat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) def at(self, center: Tensor) -> 'Geometry': From 70e107ed83d3a77619687123293e67fe9ceebb18 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:19:48 +0100 Subject: [PATCH 36/71] [field] Fix Field.sampled_at --- phi/field/_field.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/field/_field.py b/phi/field/_field.py index fd48a87ff..37147b287 100644 --- a/phi/field/_field.py +++ b/phi/field/_field.py @@ -371,7 +371,7 @@ def at_faces(self, boundary=None, **kwargs) -> 'Field': @property def sampled_at(self): - matching_sets = [s for s, s_shape in self._geometry.sets.items() if s_shape in self._values.shape] + matching_sets = [s for s, s_shape in self._geometry.sets.items() if s_shape.non_batch in self._values.shape] return matching_sets[-1] def at(self, representation: Union['Field', Geometry], keep_boundary=False, **kwargs) -> 'Field': From 2338cb135778720b5ffc4622441d0f4932e25df5 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:20:14 +0100 Subject: [PATCH 37/71] [vis] Plotly 3D equal aspect ratio --- phi/vis/_dash/_plotly_plots.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index b96165b2e..93a8ef95e 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -54,7 +54,12 @@ def create_figure(self, subplot.xaxis.update(title=bounds.vector.item_names[0], range=_get_range(bounds, 0)) subplot.yaxis.update(title=bounds.vector.item_names[1], range=_get_range(bounds, 1)) subplot.zaxis.update(title=bounds.vector.item_names[2], range=_get_range(bounds, 2)) - subplot.aspectmode = 'cube' + subplot.aspectmode = 'manual' + x_range = _get_range(bounds, 0) + y_range = _get_range(bounds, 1) + z_range = _get_range(bounds, 2) + n = float(math.length(bounds.size).max) * .5 + subplot.aspectratio = dict(x=(x_range[1]-x_range[0])/n, y=(y_range[1]-y_range[0])/n, z=(z_range[1]-z_range[0])/n) fig._phi_size = size if size[0] is not None: fig.update_layout(width=size[0] * 70) @@ -655,8 +660,8 @@ def plot_single_material(data: Field, color, alpha: float): def _get_range(bounds: Box, index: int): - lower = bounds.lower.vector[index].numpy() - upper = bounds.upper.vector[index].numpy() + lower = float(bounds.lower.vector[index]) + upper = float(bounds.upper.vector[index]) return lower, upper From 4d36f5692a965ab95364b79688a7a1e665f6e502 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:21:38 +0100 Subject: [PATCH 38/71] [vis] Fix Plotly cylinder/sphere with non-NumPy backend --- phi/vis/_dash/_plotly_plots.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index 93a8ef95e..361b02d3e 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -481,9 +481,15 @@ def plot_one_material(data, color, alpha: float): math.map(plot_one_material, data, color, alpha, dims=merge_shapes(color, alpha), unwrap_scalars=True) def _sphere_vertex_count(self, radius: Tensor, space: Box): + with math.NUMPY: + radius = math.convert(radius) + space = math.convert(space) size_in_fig = radius.max / space.size.max - vertex_count = np.clip(int(size_in_fig ** .5 * 50), 4, 64) - return vertex_count + def individual_vertex_count(size_in_fig): + if ~np.isfinite(size_in_fig): + return 0 + return np.clip(int(abs(size_in_fig) ** .5 * 50), 4, 64) + return math.map(individual_vertex_count, size_in_fig) class Scatter3D(Recipe): From d03f7a7f820998ed4a82848d86e242ca96aa8d6e Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:21:57 +0100 Subject: [PATCH 39/71] [vis] Fix Plotly mesh color --- phi/vis/_dash/_plotly_plots.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index 361b02d3e..ec84e8084 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -623,7 +623,7 @@ def plot(self, # --- plot mesh --- cbar = None if not channel(data) or not channel(data).item_names[0] else channel(data).item_names[0][0] if math.is_nan(data.values).all: - mesh = go.Mesh3d(x=x, y=y, z=z, i=v1, j=v2, k=v3, flatshading=False, opacity=float(alpha)) + mesh = go.Mesh3d(x=x, y=y, z=z, i=v1, j=v2, k=v3, flatshading=False, opacity=float(alpha), color=plotly_color(color.native())) elif data.sampled_at == 'center': values = reshaped_numpy(data.values, [instance(data.mesh)]) mesh = go.Mesh3d(x=x, y=y, z=z, i=v1, j=v2, k=v3, colorscale='viridis', colorbar_title=cbar, intensity=values, intensitymode='cell', flatshading=True, opacity=float(alpha)) @@ -656,12 +656,11 @@ def plot(self, alpha: Tensor, err: Tensor): def plot_single_material(data: Field, color, alpha: float): - color = plotly_color(color) with math.NUMPY: surf_mesh = geom.surface_mesh(data.geometry) mesh_data = Field(surf_mesh, math.NAN, 0) SurfaceMesh3D().plot(mesh_data, figure, subplot, space, min_val, max_val, show_color_bar, color, alpha, err) - math.map(plot_single_material, data, color, alpha, dims=channel(data.geometry) - 'vector') + math.map(plot_single_material, data, color, alpha, dims=channel(data.geometry) - 'vector', unwrap_scalars=False) From ce7961b8f2e191a168435a36f768d1fb96eacd14 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:22:47 +0100 Subject: [PATCH 40/71] [geom] Add build parameters to load_tri_mesh() --- phi/geom/_mesh.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index fa71950ab..a1d6e32fa 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -457,7 +457,7 @@ def __getitem__(self, item): s = math.slice return Mesh(vertices, polygons, self._element_rank, self._boundaries, self._center[item], self._volume[item], s(self._normals, item), s(self._face_centers, item), s(self._face_normals, item), s(self._face_areas, item), s(self._face_vertices, item), - s(self._vertex_normals, item), s(self._vertex_connectivity, item), self._element_connectivity[item], self._max_cell_walk) + s(self._vertex_normals, item), s(self._vertex_connectivity, item), s(self._element_connectivity, item), self._max_cell_walk) @math.broadcast @@ -956,14 +956,14 @@ def save_tri_mesh(file: str, mesh: Mesh, **extra_data): @math.broadcast -def load_tri_mesh(file: str, convert=False, load_extra=()) -> Mesh | Tuple[Mesh, ...]: +def load_tri_mesh(file: str, convert=False, load_extra=(), build_vertex_connectivity=True, build_normals=True, build_element_connectivity=True) -> Mesh | Tuple[Mesh, ...]: data = np.load(file, allow_pickle=bool(load_extra)) f_dim = instance(str(data['f_dim'])) vertex_dim = instance(str(data['vertex_dim'])) vector = channel(vector=[str(d) for d in data['vector']]) faces = tensor(data['faces'], f_dim, spatial('vertex_list'), convert=convert) vertices = tensor(data['vertices'], vertex_dim, vector, convert=convert) - m = mesh(vertices, faces, build_faces=False, build_vertex_connectivity=True, build_normals=True) + m = mesh(vertices, faces, build_faces=False, build_vertex_connectivity=build_vertex_connectivity, build_normals=build_normals, build_element_connectivity=build_element_connectivity) if not load_extra: return m extra = [data[e] for e in load_extra] From a819047ca3cf87fce30e140ff5ed8ec0677db2ab Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 3 Nov 2024 13:54:41 +0100 Subject: [PATCH 41/71] [geom] Fix Mesh.at() --- phi/geom/_mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index a1d6e32fa..c64a3f9da 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -413,7 +413,7 @@ def at(self, center: Tensor) -> 'Mesh': if instance(self._elements) in center.shape: raise NotImplementedError("Setting Mesh positions only supported for vertices, not elements") if dual(self._elements) in center.shape: - delta = rename_dims(center, dual, instance(self._vertices)) + center = rename_dims(center, dual, instance(self._vertices)) if instance(self._vertices) in center.shape: vertices = self._vertices.at(center) return mesh(vertices, self._elements, self._boundaries, build_faces=self._face_areas is not None) From 33cc44b4bc1ad5aaf509165be6cc86bdf3de97b5 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 4 Nov 2024 21:19:45 +0100 Subject: [PATCH 42/71] [geom] Add reduce to bounding_box() --- phi/geom/_box.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/phi/geom/_box.py b/phi/geom/_box.py index 9a0fd3229..84cc55f94 100644 --- a/phi/geom/_box.py +++ b/phi/geom/_box.py @@ -533,19 +533,22 @@ def lies_inside(self, location: Tensor) -> Tensor: return bool_inside -def bounding_box(geometry: Geometry | Tensor) -> Box: +def bounding_box(geometry: Geometry | Tensor, reduce=non_batch) -> Box: """ Builds a bounding box around `geometry` or a collection of points. Args: geometry: `Geometry` object or `Tensor` of points. + reduce: Which objects to includes in each bounding box. Non-reduced dims will be part of the returned box. Returns: Bounding `Box` containing only batch dims and `vector`. """ if isinstance(geometry, Tensor): assert 'vector' in geometry.shape, f"When passing a Tensor to bounding_box, it needs to have a vector dimension but got {geometry.shape}" - return Box(math.min(geometry, non_batch(geometry) - 'vector'), math.max(geometry, non_batch(geometry) - 'vector')) + reduce = geometry.shape.only(reduce) - 'vector' + return Box(math.min(geometry, reduce), math.max(geometry, reduce)) center = geometry.center extent = geometry.bounding_half_extent() - return Box(lower=center - extent, upper=center + extent) + boxes = Box(lower=center - extent, upper=center + extent) + return boxes.largest(boxes.shape.only(reduce)-'vector') From dd0255fcb1b700ec38b9fce62901d39749182643 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 4 Nov 2024 21:20:40 +0100 Subject: [PATCH 43/71] [geom] Add Cylinder.with_radius/depth --- phi/geom/_cylinder.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index ef89c949c..3ff8c0b0e 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -4,7 +4,7 @@ from phiml import math from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ - rotation_matrix_from_directions, sqrt + rotation_matrix_from_directions, sqrt, batch from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector @@ -35,7 +35,7 @@ def center(self) -> Tensor: @cached_property def shape(self) -> Shape: - return self._center.shape & self.radius.shape & self.depth.shape + return self._center.shape & self.radius.shape & self.depth.shape & batch(self.rotation) @cached_property def radial_axes(self) -> Sequence[str]: @@ -49,6 +49,12 @@ def volume(self) -> math.Tensor: def up(self): return math.rotate_vector(vec(**{d: 1 if d == self.axis else 0 for d in self._center.vector.item_names}), self.rotation) + def with_radius(self, radius: Tensor) -> 'Cylinder': + return Cylinder(self._center, radius, self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) + + def with_depth(self, depth: Tensor) -> 'Cylinder': + return Cylinder(self._center, self.radius, depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) + def lies_inside(self, location): pos = rotate_vector(location - self._center, self.rotation, invert=True) r = pos.vector[self.radial_axes] From 4aed806ce325cd082d758ae4be17bbf9372b16fb Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 4 Nov 2024 21:21:19 +0100 Subject: [PATCH 44/71] [geom] Fix NaN gradient in cylinder --- phi/geom/_cylinder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index 3ff8c0b0e..a9b65acaf 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -127,10 +127,10 @@ def sample_uniform(self, *shape: math.Shape): def bounding_radius(self): return length(vec(rad=self.radius, dep=.5*self.depth), 'vector') - def bounding_half_extent(self): + def bounding_half_extent(self, epsilon=1e-5): if self.rotation is not None: tip = abs(self.up) * .5 * self.depth - return tip + self.radius * sqrt(1 - self.up**2) + return tip + self.radius * sqrt(maximum(epsilon, 1 - self.up**2)) return ccat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) def at(self, center: Tensor) -> 'Geometry': @@ -259,7 +259,7 @@ def cylinder(center: Union[Tensor, float] = None, assert rotation is None, f"When specifying axis as a " axis_ = center.vector.item_names[-1] unit_vec = vec(**{d: 1 if d == axis_ else 0 for d in center.vector.item_names}) - rotation = rotation_matrix_from_directions(unit_vec, axis) + rotation = rotation_matrix_from_directions(unit_vec, axis, epsilon=1e-5) axis = axis_ else: rotation = rotation_matrix(rotation) From b605f19527c98e4d91da7f8bdb34c8f70891e83f Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 4 Nov 2024 21:21:46 +0100 Subject: [PATCH 45/71] =?UTF-8?q?[=CE=A6]=20Add=20median()=20to=20convenie?= =?UTF-8?q?nce=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phi/flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/flow.py b/phi/flow.py index cf31e7147..5b8497c5f 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -37,7 +37,7 @@ non_spatial, non_channel, non_batch, non_instance, non_dual, non_primal, # Shape functions (magic) unstack, stack, concat, expand, rename_dims, pack_dims, unpack_dim, flatten, cast, # Magic Ops b2i, c2b, c2d, i2b, s2b, si2d, d2i, d2s, map_s2b, map_i2b, map_c2b, map_d2c, # dim type conversions - mean, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, + mean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, scatter, gather, where, nonzero, rotate_vector as rotate, cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math From e5636af304cf518fa921f0006b1df27a58ac0fc9 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:25:52 +0100 Subject: [PATCH 46/71] [geom] Update cylinder * Use ncat * Implement face_areas() * Return instance index in approximate_closest_surface() --- phi/geom/_cylinder.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index a9b65acaf..f8209fd98 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -3,7 +3,7 @@ from typing import Union, Dict, Tuple, Optional, Sequence from phiml import math -from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ccat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ +from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ncat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ rotation_matrix_from_directions, sqrt, batch from phiml.math._magic_ops import all_attributes from phiml.math.magic import slicing_dict @@ -50,10 +50,10 @@ def up(self): return math.rotate_vector(vec(**{d: 1 if d == self.axis else 0 for d in self._center.vector.item_names}), self.rotation) def with_radius(self, radius: Tensor) -> 'Cylinder': - return Cylinder(self._center, radius, self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) + return Cylinder(self._center, wrap(radius), self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) def with_depth(self, depth: Tensor) -> 'Cylinder': - return Cylinder(self._center, self.radius, depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) + return Cylinder(self._center, self.radius, wrap(depth), self.rotation, self.axis, self.variable_attrs, self.value_attrs) def lies_inside(self, location): pos = rotate_vector(location - self._center, self.rotation, invert=True) @@ -67,7 +67,7 @@ def approximate_signed_distance(self, location: Union[Tensor, tuple]): r = location.vector[self.radial_axes] h = location.vector[self.axis] top_h = .5*self.depth - bot_h = -.5*self.depth + # bot_h = -.5*self.depth # --- Compute distances --- radial_outward = normalize(r, 'vector', epsilon=1e-5) surf_r = radial_outward * self.radius @@ -97,12 +97,12 @@ def approximate_closest_surface(self, location: Tensor): # --- Closest point on bottom / top --- above = h >= 0 flat_h = where(above, top_h, bot_h) - on_flat = ccat([flat_h, clamped_r], self._center.shape['vector']) + on_flat = ncat([flat_h, clamped_r], self._center.shape['vector']) normal_flat = where(above, self.up, -self.up) # --- Closest point on cylinder --- clamped_h = clip(h, bot_h, top_h) - on_cyl = ccat([surf_r, clamped_h], self._center.shape['vector']) - normal_cyl = ccat([radial_outward, 0], self._center.shape['vector'], expand_values=True) + on_cyl = ncat([surf_r, clamped_h], self._center.shape['vector']) + normal_cyl = ncat([radial_outward, 0], self._center.shape['vector'], expand_values=True) # --- Choose closest --- d_flat = length(on_flat - location, 'vector') d_cyl = length(on_cyl - location, 'vector') @@ -114,14 +114,15 @@ def approximate_closest_surface(self, location: Tensor): normal = where(flat_closer, normal_flat, normal_cyl) delta = rotate_vector(delta, self.rotation) normal = rotate_vector(normal, self.rotation) + idx = None if instance(self): - sgn_dist, delta, normal = math.at_min((sgn_dist, delta, normal), key=sgn_dist, dim=instance(self)) - return sgn_dist, delta, normal, None, None + sgn_dist, delta, normal, idx = math.min((sgn_dist, delta, normal, range), instance(self), key=sgn_dist) + return sgn_dist, delta, normal, None, idx def sample_uniform(self, *shape: math.Shape): r = Sphere(self._center[self.radial_axes], self.radius).sample_uniform(*shape) h = math.random_uniform(*shape, -.5*self.depth, .5*self.depth) - rh = ccat([r, h], self._center.shape['vector']) + rh = ncat([r, h], self._center.shape['vector']) return rotate_vector(rh, self.rotation) def bounding_radius(self): @@ -131,7 +132,7 @@ def bounding_half_extent(self, epsilon=1e-5): if self.rotation is not None: tip = abs(self.up) * .5 * self.depth return tip + self.radius * sqrt(maximum(epsilon, 1 - self.up**2)) - return ccat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) + return ncat([.5*self.depth, expand(self.radius, channel(vector=self.radial_axes))], self._center.shape['vector']) def at(self, center: Tensor) -> 'Geometry': return Cylinder(center, self.radius, self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) @@ -180,7 +181,9 @@ def face_centers(self) -> Tensor: @property def face_areas(self) -> Tensor: - raise NotImplementedError + flat = Sphere.volume_from_radius(self.radius, self.spatial_rank - 1) + lateral = 2*PI*self.radius * self.depth + return stack({'bottom': flat, 'top': flat, 'lateral': lateral}, dual('shell'), expand_values=True) @property def face_normals(self) -> Tensor: @@ -212,7 +215,7 @@ def vertex_rings(self, count: Shape) -> Tensor: s = sin(angle) * self.radius c = cos(angle) * self.radius r = stack([s, c], channel(vector=self.radial_axes)) - x = ccat([h, r], self._center.shape['vector'], expand_values=True) + x = ncat([h, r], self._center.shape['vector'], expand_values=True) return math.rotate_vector(x, self.rotation) + self._center raise NotImplementedError From ee893146a639ef55abe7fcdcde7c17500f1e8b8b Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:26:28 +0100 Subject: [PATCH 47/71] [geom] Add UniformGrid.with_scaled_resolution() --- phi/geom/_grid.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phi/geom/_grid.py b/phi/geom/_grid.py index 71e644cd9..84dd6a5a9 100644 --- a/phi/geom/_grid.py +++ b/phi/geom/_grid.py @@ -134,6 +134,9 @@ def half_size(self): def rotation_matrix(self) -> Optional[Tensor]: return None + def with_scaled_resolution(self, scale: float): + return UniformGrid(self._resolution * scale, self._bounds) + def __getitem__(self, item): item = slicing_dict(self, item) resolution = self._resolution.after_gather(item) From 10df2bd882b68e0586eeace322de8e029b6e2a72 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:27:12 +0100 Subject: [PATCH 48/71] [geom] SDFGrid fix out-of-bounds, add property type hints --- phi/geom/_sdf_grid.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/phi/geom/_sdf_grid.py b/phi/geom/_sdf_grid.py index 60e7baf5a..062079308 100644 --- a/phi/geom/_sdf_grid.py +++ b/phi/geom/_sdf_grid.py @@ -2,7 +2,7 @@ from typing import Union, Tuple, Dict, Any, Optional, Sequence from phiml import math -from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch, dual +from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch, dual, clip, wrap from phiml.math.magic import slicing_dict from . import UniformGrid from ._geom import Geometry @@ -78,27 +78,27 @@ def with_values(self, values: Tensor): return SDFGrid(values, self._bounds, self._approximate_outside, self._grad, self._center, self._volume, self._bounding_radius) @property - def bounds(self): + def bounds(self) -> BaseBox: return self._bounds @property - def size(self): + def size(self) -> Tensor: return self._bounds.size @property - def resolution(self): + def resolution(self) -> Shape: return spatial(self._sdf) @property - def dx(self): + def dx(self) -> Tensor: return self._bounds.size / spatial(self._sdf) @property - def points(self): + def points(self) -> Tensor: return UniformGrid(spatial(self._sdf), self._bounds).center @property - def grid(self): + def grid(self) -> UniformGrid: return UniformGrid(spatial(self._sdf), self._bounds) @property @@ -168,7 +168,7 @@ def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, surface_pos = location + to_surf if self._surf_normal is not None: normal = math.grid_sample(self._surf_normal, float_idx - .5, math.extrapolation.ZERO_GRADIENT) - int_index = math.to_int32(float_idx) + int_index = clip(math.to_int32(float_idx), 0, wrap(spatial(self._surf_index), '(x,y,z)')-1) face_index = self._surf_index[int_index] else: surf_float_idx = (surface_pos - self._bounds.lower) / self.size * self.resolution From 4980be59a5b443380d3e3ad4bf18bccc0dc4e78e Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:27:28 +0100 Subject: [PATCH 49/71] [vis] Fix color assignment --- phi/vis/_vis.py | 11 ++++++----- phi/vis/_vis_base.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/phi/vis/_vis.py b/phi/vis/_vis.py index 2492d621c..e91165be4 100644 --- a/phi/vis/_vis.py +++ b/phi/vis/_vis.py @@ -436,11 +436,12 @@ def layout_color(content: Dict[Tuple[int, int], List[Field]], indices: Dict[Tupl idx = indices[pos][i] if (color[idx] != None).all: # user-specified color result_pos.append(color[idx]) - cmap = requires_color_map(f) - channels = channel(f).without('vector') - channel_colors = counter + math.range_tensor(channels) - result_pos.append(math.where(cmap, wrap('cmap'), channel_colors)) - counter += channels.volume * math.any(~cmap, shape) + else: + cmap = requires_color_map(f) + channels = channel(f).without('vector') + channel_colors = counter + math.range_tensor(channels) + result_pos.append(math.where(cmap, wrap('cmap'), channel_colors)) + counter += channels.volume * math.any(~cmap, shape) return result diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index bbb94f2a6..892c7bc1a 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -607,7 +607,7 @@ def uniform_bound(shape: Shape): def requires_color_map(f: Field): if f.spatial_rank <= 1: return False - return math.is_finite(f.values).any + return bool(math.is_finite(f.values).any) def is_jupyter(): From c4ac2371326da3744a9e568c45074fd59c6e4eb2 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:28:33 +0100 Subject: [PATCH 50/71] [geom] Add private distance_line_point() --- phi/geom/_functions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/phi/geom/_functions.py b/phi/geom/_functions.py index f9fa6c99a..2bbf68bca 100644 --- a/phi/geom/_functions.py +++ b/phi/geom/_functions.py @@ -129,3 +129,11 @@ def closest_points_on_lines(p1, v1, p2, v2, eps=1e-10, can_be_parallel=True): c1 = where(is_parallel, p1 + t * v1, c1) c2 = where(is_parallel, p2, c2) return c1, c2 + + +def distance_line_point(line_offset: Tensor, line_direction: Tensor, point: Tensor, is_direction_normalized=False) -> Tensor: + to_point = point - line_offset + c = vec_length(cross_product(to_point, line_direction)) + if not is_direction_normalized: + c /= vec_length(line_direction) + return c From 4fd6c9a711c75fdcff6e95b88b824297264e0b78 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 12:29:02 +0100 Subject: [PATCH 51/71] =?UTF-8?q?[=CE=A6]=20Add=20more=20math=20functions?= =?UTF-8?q?=20to=20convenience=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phi/flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/phi/flow.py b/phi/flow.py index 5b8497c5f..1ed7476a9 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -32,12 +32,12 @@ # Functions from phiml.math import ( - wrap, tensor, vec, zeros, zeros_like, ones, ones_like, linspace, # Tensor creation + wrap, tensor, vec, zeros, zeros_like, ones, ones_like, linspace, rand, randn, # Tensor creation shape, spatial, channel, batch, instance, dual, primal, non_spatial, non_channel, non_batch, non_instance, non_dual, non_primal, # Shape functions (magic) - unstack, stack, concat, expand, rename_dims, pack_dims, unpack_dim, flatten, cast, # Magic Ops - b2i, c2b, c2d, i2b, s2b, si2d, d2i, d2s, map_s2b, map_i2b, map_c2b, map_d2c, # dim type conversions - mean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, + unstack, stack, concat, tcat, expand, rename_dims, pack_dims, unpack_dim, flatten, cast, # Magic Ops + b2i, c2b, c2d, i2b, s2b, si2d, d2i, d2s, map_s2b, map_i2b, map_c2b, map_d2b, map_d2c, map_c2d, # dim type conversions + dsum, isum, ssum, csum, mean, dmean, imean, smean, cmean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, scatter, gather, where, nonzero, rotate_vector as rotate, cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math From 84618055d2508c7a2ee6e1b5d5a8aceb10a61afc Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 22 Nov 2024 18:51:48 +0100 Subject: [PATCH 52/71] [field] Make Field a dataclass, update docstrings --- phi/field/_field.py | 336 +++++++++++++++++++------------------- phi/field/_point_cloud.py | 4 +- 2 files changed, 166 insertions(+), 174 deletions(-) diff --git a/phi/field/_field.py b/phi/field/_field.py index 37147b287..0db9ab6f8 100644 --- a/phi/field/_field.py +++ b/phi/field/_field.py @@ -1,4 +1,5 @@ import warnings +from dataclasses import dataclass from numbers import Number from typing import Callable, Union, Tuple, Optional @@ -19,93 +20,103 @@ def _sample(self, geometry: Geometry, at: str, boundaries: Extrapolation, **kwar raise NotImplementedError(self) -class Field: - """ - Base class for all fields. - - Important implementations: - - * CenteredGrid - * StaggeredGrid - * PointCloud - * Noise - - See the `phi.field` module documentation at https://tum-pbs.github.io/PhiFlow/Fields.html - """ - - def __init__(self, +class _FieldType(type): + """Metaclass for the Field constructor.""" + def __call__(cls, geometry: Union[Geometry, Tensor], values: Union[Tensor, Number, bool, Callable, FieldInitializer, Geometry, 'Field'], - boundary: Union[Number, Extrapolation, 'Field', dict] = 0, + boundary: Union[Number, Extrapolation, 'Field', dict] = 0., + variable_attrs=('values',), + value_attrs=('values',), **sampling_kwargs): - """ - Args: - elements: Geometry object specifying the sample points and sizes - values: values corresponding to elements - extrapolation: values outside elements - """ assert isinstance(geometry, Geometry), f"geometry must be a Geometry object but got {type(geometry).__name__}" - self._boundary: Extrapolation = as_boundary(boundary, geometry) - self._geometry: Geometry = geometry - if isinstance(values, (Tensor, Number, bool)): - values = wrap(values) - else: - from ._resample import sample - values = sample(values, geometry, 'center', self._boundary, **sampling_kwargs) - matching_sets = [s for s, s_shape in geometry.sets.items() if s_shape in values.shape] - if not matching_sets: - values = expand(wrap(values), non_batch(geometry) - 'vector') - self._values: Tensor = values - math.merge_shapes(values, non_batch(self.sampled_elements).non_channel) # shape check + boundary = as_boundary(boundary, geometry) + if values is not None: + if isinstance(values, (Tensor, Number, bool)): + values = wrap(values) + else: + from ._resample import sample + values = sample(values, geometry, 'center', boundary, **sampling_kwargs) + matching_sets = [s for s, s_shape in geometry.sets.items() if s_shape in values.shape] + if not matching_sets: + values = expand(wrap(values), non_batch(geometry) - 'vector') + result = cls.__new__(cls, geometry, values, boundary, variable_attrs, value_attrs) + result.__init__(geometry, values, boundary, variable_attrs, value_attrs) # also calls __post_init__() + return result - @property - def geometry(self) -> Geometry: - """ - Returns a geometrical representation of the discrete volume elements. - The result is a tuple of Geometry objects, each of which can have additional spatial (but not batch) dimensions. - - For grids, the geometries are boxes while particle fields may be represented as spheres. - - If this Field has no discrete points, this method returns an empty geometry. - """ - return self._geometry + +@dataclass(frozen=True) +class Field(metaclass=_FieldType): + """ + A `Field` represents a discretized physical quantity (like temperature field or velocity field). + The sample points and their relation are encoded in the `geometry` property and the corresponding values are stored as one `Tensor` in `values`. + The boundary conditions and values outside the geometry are determined by `boundary`. + + Examples: + Create a periodic 2D grid, initialized via noise fluctuations. + >>> Field(UniformGrid(x=32, y=32), values=Noise(), boundary=PERIODIC) + + Create a field on an unstructured mesh loaded from a .gmsh file + >>> mesh = phi.geom.load_gmsh('cylinder.msh', ('y-', 'x+', 'y+', 'x-', 'cyl+', 'cyl-')) + >>> Field(mesh, values=vec(x=1, y=0), boundary={'x': ZERO_GRADIENT, 'y': 0, 'cyl': 0}) + + Create two cubes and compute a scalar values for each. + >>> Field(Cuboid(vec(x=[0, 2], y=0), x=1, y=1), values=lambda x,y: x) + + See the `phi.field` module documentation at https://tum-pbs.github.io/PhiFlow/Fields.html + """ + + geometry: Geometry + """ Discretization `Geometry`. This determines where in space the `values` are sampled as well as their relationship and interpretation. """ + values: Tensor + """ The sampled values, matching some point set of `geometry`, e.g. center points, see `Geometry.sets`.""" + boundary: Extrapolation = 0. + """ Boundary conditions describe the values outside of `geometry` and are used by numerical solvers to compute edge values. """ + + variable_attrs: Tuple[str, ...] = ('values',) + """ Which of the three attributes (geometry,values,boundary) should be traced / optimized. See `phiml.math.magic.PhiTreeNode.__variable_attrs__`""" + value_attrs: Tuple[str, ...] = ('values',) + """ Which of the three attributes (geometry,values,boundary) are considered values. See `phiml.math.magic.PhiTreeNode.__value_attrs__`""" + + def __post_init__(self): + math.merge_shapes(self.values, non_batch(self.sampled_elements).non_channel) # shape check @property def grid(self) -> UniformGrid: """Cast `self.geometry` to a `phi.geom.UniformGrid`.""" - assert isinstance(self._geometry, UniformGrid), f"Geometry is not a UniformGrid but {type(self._geometry)}" - return self._geometry + assert isinstance(self.geometry, UniformGrid), f"Geometry is not a UniformGrid but {type(self.geometry)}" + return self.geometry @property def mesh(self) -> Mesh: """Cast `self.geometry` to a `phi.geom.Mesh`.""" - assert isinstance(self._geometry, Mesh), f"Geometry is not a mesh but {type(self._geometry)}" - return self._geometry + assert isinstance(self.geometry, Mesh), f"Geometry is not a mesh but {type(self.geometry)}" + return self.geometry @property def graph(self) -> Graph: """Cast `self.geometry` to a `phi.geom.Graph`.""" - assert isinstance(self._geometry, Graph), f"Geometry is not a mesh but {type(self._geometry)}" - return self._geometry + assert isinstance(self.geometry, Graph), f"Geometry is not a mesh but {type(self.geometry)}" + return self.geometry @property def faces(self): - return get_faces(self._geometry, self._boundary) + return get_faces(self.geometry, self.boundary) @property def face_centers(self): - return self._geometry.face_centers - # return slice_off_constant_faces(self._geometry.face_centers, self._geometry.boundary_faces, self._boundary) + return self.geometry.face_centers + # return slice_off_constant_faces(self.geometry.face_centers, self.geometry.boundary_faces, self.boundary) @property def face_normals(self): - return self._geometry.face_normals - # return slice_off_constant_faces(self._geometry.face_normals, self._geometry.boundary_faces, self._boundary) + return self.geometry.face_normals + # return slice_off_constant_faces(self.geometry.face_normals, self.geometry.boundary_faces, self.boundary) @property def face_areas(self): - return self._geometry.face_areas - # return slice_off_constant_faces(self._geometry.face_areas, self._geometry.boundary_faces, self._boundary) + return self.geometry.face_areas + # return slice_off_constant_faces(self.geometry.face_areas, self.geometry.boundary_faces, self.boundary) @property def sampled_elements(self) -> Geometry: @@ -113,13 +124,13 @@ def sampled_elements(self) -> Geometry: If the values represent are sampled at the element centers or represent the whole element, returns `self.geometry`. If the values are sampled at the faces, returns `self.faces`. """ - return get_faces(self._geometry, self._boundary) if is_staggered(self._values, self._geometry) else self._geometry + return get_faces(self.geometry, self.boundary) if is_staggered(self.values, self.geometry) else self.geometry @property def elements(self): # raise SyntaxError("Field.elements is deprecated. Use Field.geometry or Field.sampled_elements instead.") warnings.warn("Field.elements is deprecated. Use Field.geometry or Field.sampled_elements instead. Field.elements now defaults to Field.geometry.", DeprecationWarning, stacklevel=2) - return self._geometry + return self.geometry @property def is_centered(self): @@ -127,13 +138,13 @@ def is_centered(self): @property def is_staggered(self): - return is_staggered(self._values, self._geometry) + return is_staggered(self.values, self.geometry) @property def center(self) -> Tensor: """ Returns the center points of the `elements` of this `Field`. """ - all_points = self._geometry.get_points(self.sampled_at) - boundary = self._geometry.get_boundary(self.sampled_at) + all_points = self.geometry.get_points(self.sampled_at) + boundary = self.geometry.get_boundary(self.sampled_at) return slice_off_constant_faces(all_points, boundary, self.extrapolation) @property @@ -141,11 +152,8 @@ def points(self): return self.center @property - def values(self) -> Tensor: - """ Returns the `values` of this `Field`. """ - return self._values - - data = values + def data(self) -> Tensor: + return self.values def numpy(self, order: DimFilter = None): """ @@ -158,16 +166,16 @@ def numpy(self, order: DimFilter = None): A single NumPy array for uniform values, else a list of NumPy arrays. """ if order is None and self.is_grid: - axes = self._values.shape.only(self._geometry.vector.item_names, reorder=True) - order = concat_shapes(self._values.shape.dual, self._values.shape.batch, axes, self._values.shape.channel) - if self._values.shape.is_uniform: - return self._values.numpy(order) + axes = self.values.shape.only(self.geometry.vector.item_names, reorder=True) + order = concat_shapes(self.values.shape.dual, self.values.shape.batch, axes, self.values.shape.channel) + if self.values.shape.is_uniform: + return self.values.numpy(order) else: assert order is not None, f"order must be specified for non-uniform Field values" - order = self._values.shape.only(order, reorder=True) + order = self.values.shape.only(order, reorder=True) stack_dims = order.non_uniform_shape inner_order = order.without(stack_dims) - return [v.numpy(inner_order) for v in unstack(self._values, stack_dims)] + return [v.numpy(inner_order) for v in unstack(self.values, stack_dims)] def uniform_values(self): """ @@ -181,20 +189,10 @@ def uniform_values(self): else: return self.staggered_tensor() - @property - def boundary(self) -> Extrapolation: - """ - Returns the boundary conditions set for this `Field`. - - Returns: - Single `Extrapolation` instance that encodes the (varying) boundary conditions for all boundaries of this field's `elements`. - """ - return self._boundary - @property def extrapolation(self) -> Extrapolation: """ Returns the `Extrapolation` of this `Field`. """ - return self._boundary + return self.boundary @property def shape(self) -> Shape: @@ -205,14 +203,14 @@ def shape(self) -> Shape: * The batch dimensions match the batch dimensions of this Field * The channel dimensions match the channels of this Field """ - if self.is_grid and '~vector' in self._values.shape: - return batch(self._geometry) & self.resolution & non_dual(self._values).without(self.resolution) & self._geometry.shape['vector'] - set_shape = self._geometry.sets[self.sampled_at] - return batch(self._geometry) & (channel(self._geometry) - 'vector') & set_shape & self._values + if self.is_grid and '~vector' in self.values.shape: + return batch(self.geometry) & self.resolution & non_dual(self.values).without(self.resolution) & self.geometry.shape['vector'] + set_shape = self.geometry.sets[self.sampled_at] + return batch(self.geometry) & (channel(self.geometry) - 'vector') & set_shape & self.values @property def resolution(self): - return self._geometry.shape.non_channel.non_dual.non_batch + return self.geometry.shape.non_channel.non_dual.non_batch @property def spatial_rank(self) -> int: @@ -220,7 +218,7 @@ def spatial_rank(self) -> int: Spatial rank of the field (1 for 1D, 2 for 2D, 3 for 3D). This is equal to the spatial rank of the `data`. """ - return self._geometry.spatial_rank + return self.geometry.spatial_rank @property def bounds(self) -> BaseBox: @@ -234,10 +232,10 @@ def bounds(self) -> BaseBox: Fields whose spatial rank is determined only during sampling return an empty `Box`. """ - if isinstance(self._geometry.bounds, BaseBox): - return self._geometry.bounds - extent = self._geometry.bounding_half_extent().vector.as_dual('_extent') - points = self._geometry.center + extent + if isinstance(self.geometry.bounds, BaseBox): + return self.geometry.bounds + extent = self.geometry.bounding_half_extent().vector.as_dual('_extent') + points = self.geometry.center + extent lower = math.min(points, dim=points.shape.non_batch.non_channel) upper = math.max(points, dim=points.shape.non_batch.non_channel) return Box(lower, upper) @@ -247,36 +245,36 @@ def bounds(self) -> BaseBox: @property def is_grid(self): """A Field represents grid data if its `geometry` is a `phi.geom.UniformGrid` instance.""" - return isinstance(self._geometry, UniformGrid) + return isinstance(self.geometry, UniformGrid) @property def is_mesh(self): """A Field represents mesh data if its `geometry` is a `phi.geom.Mesh` instance.""" - return isinstance(self._geometry, Mesh) + return isinstance(self.geometry, Mesh) @property def is_graph(self): """A Field represents graph data if its `geometry` is a `phi.geom.Graph` instance.""" - return isinstance(self._geometry, Graph) + return isinstance(self.geometry, Graph) @property def is_point_cloud(self): """A Field represents graph data if its `geometry` is not a set of connected elements, but rather individual geometric objects.""" - if isinstance(self._geometry, (UniformGrid, Mesh, Graph)): + if isinstance(self.geometry, (UniformGrid, Mesh, Graph)): return False - if isinstance(self._geometry, (BaseBox, Sphere, Point)): + if isinstance(self.geometry, (BaseBox, Sphere, Point)): return True return True @property def dx(self) -> Tensor: - assert spatial(self._geometry), f"dx is only defined for elements with spatial dims but Field has elements {self._geometry.shape}" + assert spatial(self.geometry), f"dx is only defined for elements with spatial dims but Field has elements {self.geometry.shape}" return self.bounds.size / self.resolution @property def cells(self): - assert isinstance(self._geometry, (UniformGrid, Mesh)) - return self._geometry + assert isinstance(self.geometry, (UniformGrid, Mesh)) + return self.geometry def to_grid(self, resolution=math.EMPTY_SHAPE, bounds=None, **resolution_): resolution = resolution.spatial & spatial(**resolution_) @@ -284,11 +282,11 @@ def to_grid(self, resolution=math.EMPTY_SHAPE, bounds=None, **resolution_): return self bounds = self.bounds if bounds is None else bounds if not resolution: - half_sizes = self._geometry.bounding_half_extent() + half_sizes = self.geometry.bounding_half_extent() if (half_sizes > 0).all: size = math.min(2 * half_sizes, non_batch(half_sizes).non_channel) else: - cell_count = non_batch(self._geometry).non_channel.non_dual.volume + cell_count = non_batch(self.geometry).non_channel.non_dual.volume size = (bounds.volume / cell_count) ** (1 / self.spatial_rank) res = math.maximum(1, math.round(bounds.size / size)) resolution = spatial(**res.vector) @@ -310,12 +308,12 @@ def as_points(self, list_dim: Optional[Shape] = instance('elements')) -> 'Field' `Field` with same values and boundaries but `Point` geometry. """ points = self.sampled_elements.center - values = self._values + values = self.values if list_dim: dims = non_batch(points).non_channel & non_batch(points).non_channel points = pack_dims(points, dims, list_dim) values = pack_dims(values, dims, list_dim) - return Field(Point(points), values, self._boundary) + return Field(Point(points), values, self.boundary) def as_spheres(self, list_dim: Optional[Shape] = instance('elements')) -> 'Field': """ @@ -334,13 +332,13 @@ def as_spheres(self, list_dim: Optional[Shape] = instance('elements')) -> 'Field """ points = self.sampled_elements.center volumes = self.sampled_elements.volume - values = self._values + values = self.values if list_dim: dims = non_batch(points).non_channel & non_batch(points).non_channel points = pack_dims(points, dims, list_dim) values = pack_dims(values, dims, list_dim) volumes = pack_dims(volumes, dims, list_dim) - return Field(Sphere(points, volume=volumes), values, self._boundary) + return Field(Sphere(points, volume=volumes), values, self.boundary) def at_centers(self, **kwargs) -> 'Field': """ @@ -358,20 +356,20 @@ def at_centers(self, **kwargs) -> 'Field': if self.is_centered: return self from ._resample import sample - values = sample(self, self._geometry, at='center', boundary=self._boundary, **kwargs) - return Field(self._geometry, values, self._boundary) + values = sample(self, self.geometry, at='center', boundary=self.boundary, **kwargs) + return Field(self.geometry, values, self.boundary) def at_faces(self, boundary=None, **kwargs) -> 'Field': if self.is_staggered and not boundary: return self - boundary = as_boundary(boundary, self._geometry) if boundary else self._boundary + boundary = as_boundary(boundary, self.geometry) if boundary else self.boundary from ._resample import sample - values = sample(self, self._geometry, at='face', boundary=boundary, **kwargs) - return Field(self._geometry, values, boundary) + values = sample(self, self.geometry, at='face', boundary=boundary, **kwargs) + return Field(self.geometry, values, boundary) @property def sampled_at(self): - matching_sets = [s for s, s_shape in self._geometry.sets.items() if s_shape.non_batch in self._values.shape] + matching_sets = [s for s, s_shape in self.geometry.sets.items() if s_shape.non_batch in self.values.shape] return matching_sets[-1] def at(self, representation: Union['Field', Geometry], keep_boundary=False, **kwargs) -> 'Field': @@ -435,52 +433,52 @@ def with_values(self, values, **sampling_kwargs): """ Returns a copy of this field with `values` replaced. """ if not isinstance(values, (Tensor, Number)): from ._resample import sample - values = sample(values, self._geometry, self.sampled_at, self._boundary, dot_face_normal=self._geometry if 'vector' not in self._values.shape else None, **sampling_kwargs) + values = sample(values, self.geometry, self.sampled_at, self.boundary, dot_face_normal=self.geometry if 'vector' not in self.values.shape else None, **sampling_kwargs) else: if not spatial(values): - geo_shape = self.sampled_elements.shape if self.is_staggered else self._geometry.shape + geo_shape = self.sampled_elements.shape if self.is_staggered else self.geometry.shape if '~vector' in geo_shape and 'vector' in shape(values) and '~vector' not in shape(values): values = values.vector.as_dual() values = expand(wrap(values), geo_shape.non_batch.non_channel) - return Field(self._geometry, values, self._boundary) + return Field(self.geometry, values, self.boundary) def with_boundary(self, boundary): """ Returns a copy of this field with the `boundary` replaced. """ - boundary = as_boundary(boundary, self._geometry) + boundary = as_boundary(boundary, self.geometry) boundary_elements = 'boundary_faces' if self.is_staggered else 'boundary_elements' - old_determined_slices = {k: s for k, s in getattr(self._geometry, boundary_elements).items() if self._boundary.determines_boundary_values(k)} - new_determined_slices = {k: s for k, s in getattr(self._geometry, boundary_elements).items() if boundary.determines_boundary_values(k)} + old_determined_slices = {k: s for k, s in getattr(self.geometry, boundary_elements).items() if self.boundary.determines_boundary_values(k)} + new_determined_slices = {k: s for k, s in getattr(self.geometry, boundary_elements).items() if boundary.determines_boundary_values(k)} if old_determined_slices.values() == new_determined_slices.values(): - return Field(self._geometry, self._values, boundary) # ToDo unnecessary once the rest is implemented + return Field(self.geometry, self.values, boundary) # ToDo unnecessary once the rest is implemented to_add = {k: sl for k, sl in old_determined_slices.items() if sl not in new_determined_slices.values()} to_remove = [sl for sl in new_determined_slices.values() if sl not in old_determined_slices.values()] - values = math.slice_off(self._values, *to_remove) + values = math.slice_off(self.values, *to_remove) if to_add: if self.is_mesh: - values = self.mesh.pad_boundary(values, to_add, self._boundary) + values = self.mesh.pad_boundary(values, to_add, self.boundary) elif self.is_grid and self.is_staggered: - values = self._values.vector.dual.as_channel() + values = self.values.vector.dual.as_channel() to_add = {k: {'vector' if dim == '~vector' else dim: v for dim, v in sl.items()} for k, sl in to_add.items()} - values = math.pad(values, list(to_add.values()), self._boundary, bounds=self.bounds) + values = math.pad(values, list(to_add.values()), self.boundary, bounds=self.bounds) values = values.vector.as_dual() else: - values = math.pad(values, list(to_add.values()), self._boundary, bounds=self.bounds) - return Field(self._geometry, values, boundary) + values = math.pad(values, list(to_add.values()), self.boundary, bounds=self.bounds) + return Field(self.geometry, values, boundary) with_extrapolation = with_boundary def with_bounds(self, bounds: Box): """ Returns a copy of this field with `bounds` replaced. """ order = list(bounds.vector.item_names) - geometry = self._geometry.vector[order] - new_shape = self._values.shape.without(order) & self._values.shape.only(order, reorder=True) - values = math.transpose(self._values, new_shape) - return Field(geometry, values, self._boundary) + geometry = self.geometry.vector[order] + new_shape = self.values.shape.without(order) & self.values.shape.only(order, reorder=True) + values = math.transpose(self.values, new_shape) + return Field(geometry, values, self.boundary) def with_geometry(self, elements: Geometry): """ Returns a copy of this field with `elements` replaced. """ - assert non_batch(elements) == non_batch(self._geometry), f"Field.with_elements() only accepts elements with equal non-batch dimensions but got {elements.shape} for Field with shape {self._geometry.shape}" - return Field(elements, self._values, self._boundary) + assert non_batch(elements) == non_batch(self.geometry), f"Field.with_elements() only accepts elements with equal non-batch dimensions but got {elements.shape} for Field with shape {self.geometry.shape}" + return Field(elements, self.values, self.boundary) with_elements = with_geometry @@ -497,7 +495,7 @@ def shifted(self, delta: Tensor) -> 'Field': Returns: New `Field` sampled at `geometry.center + delta`. """ - return self.with_geometry(self._geometry.shifted(delta)) + return self.with_geometry(self.geometry.shifted(delta)) def shifted_to(self, position: Tensor) -> 'Field': """ @@ -512,7 +510,7 @@ def shifted_to(self, position: Tensor) -> 'Field': Returns: New `Field` sampled at given positions. """ - return self.with_geometry(self._geometry.at(position)) + return self.with_geometry(self.geometry.at(position)) def pad(self, widths: Union[int, tuple, list, dict]) -> 'Field': """ @@ -664,14 +662,14 @@ def __getitem__(self, item) -> 'Field': item = slicing_dict(self, item) if not item: return self - boundary = domain_slice(self._boundary, item, self.resolution) + boundary = domain_slice(self.boundary, item, self.resolution) item_without_vec = {dim: selection for dim, selection in item.items() if dim != 'vector'} - geometry = self._geometry[item_without_vec] + geometry = self.geometry[item_without_vec] if self.is_staggered and 'vector' in item and '~vector' in self.geometry.face_shape: - assert isinstance(self._geometry, UniformGrid), f"Vector slicing is only supported for grids" + assert isinstance(self.geometry, UniformGrid), f"Vector slicing is only supported for grids" dims = item['vector'] - dims_ = self._geometry.shape['vector'].after_gather({'vector': dims}) - dims = dims_.item_names[0] if dims_ else [dims] if isinstance(dims, str) else [self._geometry.shape['vector'].item_names[0][dims]] + dims_ = self.geometry.shape['vector'].after_gather({'vector': dims}) + dims = dims_.item_names[0] if dims_ else [dims] if isinstance(dims, str) else [self.geometry.shape['vector'].item_names[0][dims]] proj_dims = set(self.resolution.names) - set(dims) if any(dim not in item for dim in proj_dims): # warnings.warn(f"Projecting a staggered grid (by slicing 'vector' without the corresponding spatial dims) will return a non-staggered grid. The projected dims {proj_dims} were not sliced off.\nFull slice: {item}") @@ -681,7 +679,7 @@ def __getitem__(self, item) -> 'Field': else: item['~vector'] = dims del item['vector'] - values = self._values[item] + values = self.values[item] return Field(geometry, values, boundary) def __getattr__(self, name: str) -> BoundDim: @@ -706,32 +704,26 @@ def dimension(self, name: str): """ return BoundDim(self, name) - def __value_attrs__(self): - return '_values', - - def __variable_attrs__(self): - return '_values', '_geometry', '_boundary' - def __expand__(self, dims: Shape, **kwargs) -> 'Field': return self.with_values(expand(self.values, dims, **kwargs)) def __replace_dims__(self, dims: Tuple[str, ...], new_dims: Shape, **kwargs) -> 'Field': - elements = math.rename_dims(self._geometry, dims, new_dims) - values = math.rename_dims(self._values, dims, new_dims) - extrapolation = math.rename_dims(self._boundary, dims, new_dims, **kwargs) + elements = math.rename_dims(self.geometry, dims, new_dims) + values = math.rename_dims(self.values, dims, new_dims) + extrapolation = math.rename_dims(self.boundary, dims, new_dims, **kwargs) return Field(elements, values, extrapolation) def __eq__(self, other): if not isinstance(other, Field): return False - if self._geometry != other._geometry: + if self.geometry != other.geometry: return False - if self._boundary != other.boundary: + if self.boundary != other.boundary: return False - return math.always_close(self._values, other._values) + return math.always_close(self.values, other.values) def __hash__(self): - return hash((self._geometry, self._boundary)) + return hash((self.geometry, self.boundary)) def __mul__(self, other): return self._op2(other, lambda d1, d2: d1 * d2) @@ -787,35 +779,35 @@ def _op1(self: 'Field', operator: Callable) -> 'Field': Field of same type """ values = operator(self.values) - extrapolation_ = operator(self._boundary) + extrapolation_ = operator(self.boundary) return self.with_values(values).with_extrapolation(extrapolation_) def _op2(self, other, operator) -> 'Field': if isinstance(other, Geometry): raise ValueError(f"Cannot combine {self.__class__.__name__} with a Geometry, got {type(other)}") if isinstance(other, Field): - if self._geometry == other._geometry: - values = operator(self._values, other.values) - extrapolation_ = operator(self._boundary, other.extrapolation) - return Field(self._geometry, values, extrapolation_) + if self.geometry == other.geometry: + values = operator(self.values, other.values) + extrapolation_ = operator(self.boundary, other.extrapolation) + return Field(self.geometry, values, extrapolation_) from ._resample import sample - other_values = sample(other, self._geometry, self.sampled_at, self.boundary, dot_face_normal=self._geometry) - values = operator(self._values, other_values) - boundary = operator(self._boundary, other.extrapolation) - return Field(self._geometry, values, boundary) + other_values = sample(other, self.geometry, self.sampled_at, self.boundary, dot_face_normal=self.geometry) + values = operator(self.values, other_values) + boundary = operator(self.boundary, other.extrapolation) + return Field(self.geometry, values, boundary) else: if isinstance(other, (tuple, list)) and len(other) == self.spatial_rank: - other = math.wrap(other, self._geometry.shape['vector']) + other = math.wrap(other, self.geometry.shape['vector']) else: other = math.wrap(other) # try: - # boundary = operator(self._boundary, as_boundary(other, self._geometry)) + # boundary = operator(self.boundary, as_boundary(other, self.geometry)) # except TypeError: # e.g. ZERO_GRADIENT + constant - boundary = self._boundary # constants don't affect the boundary conditions (legacy reasons) + boundary = self.boundary # constants don't affect the boundary conditions (legacy reasons) if 'vector' in self.shape and 'vector' not in self.values.shape and '~vector' in self.values.shape: other = other.vector.as_dual() - values = operator(self._values, other) - return Field(self._geometry, values, boundary) + values = operator(self.values, other) + return Field(self.geometry, values, boundary) def __repr__(self): if self.is_grid: @@ -828,10 +820,10 @@ def __repr__(self): type_name = "Graph" if self.is_centered else "Graph edges" else: type_name = self.__class__.__name__ - if self._values is not None: - return f"{type_name}[{self.values}, ext={self._boundary}]" + if self.values is not None: + return f"{type_name}[{self.values}, ext={self.boundary}]" else: - return f"{type_name}[{self.resolution}, ext={self._boundary}]" + return f"{type_name}[{self.resolution}, ext={self.boundary}]" def grid_scatter(self, *args, **kwargs): """Deprecated. Use `sample` with `scatter=True` instead.""" diff --git a/phi/field/_point_cloud.py b/phi/field/_point_cloud.py index fc2898120..19dee5e0f 100644 --- a/phi/field/_point_cloud.py +++ b/phi/field/_point_cloud.py @@ -10,7 +10,7 @@ from ..math.extrapolation import Extrapolation, ConstantExtrapolation, PERIODIC -def PointCloud(elements: Union[Tensor, Geometry, float], values: Any = 1., extrapolation: Union[Extrapolation, float] = 0., bounds: Box = None) -> Field: +def PointCloud(elements: Union[Tensor, Geometry, float], values: Any = 1., extrapolation: Union[Extrapolation, float] = 0., bounds: Box = None, variable_attrs=('values', 'geometry'), value_attrs=('values',)) -> Field: """ A `PointCloud` comprises: @@ -53,7 +53,7 @@ def PointCloud(elements: Union[Tensor, Geometry, float], values: Any = 1., extra elements = values * 0 if isinstance(elements, Tensor): elements = geom.Point(elements) - result = Field(elements, values, extrapolation) + result = Field(elements, values, extrapolation, variable_attrs, value_attrs) assert result.boundary is PERIODIC or isinstance(result.boundary, ConstantExtrapolation), f"Unsupported extrapolation for PointCloud: {result._boundary}" return result From 5f975ffdf3b5afe8e9875a7ced8f8934f205d405 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 24 Nov 2024 17:44:33 +0100 Subject: [PATCH 53/71] [geom] Update Cylinder to use getitem_dataclass() --- phi/geom/_cylinder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index f8209fd98..f519b36bb 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -5,7 +5,7 @@ from phiml import math from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ncat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ rotation_matrix_from_directions, sqrt, batch -from phiml.math._magic_ops import all_attributes +from phiml.math._magic_ops import all_attributes, getitem_dataclass from phiml.math.magic import slicing_dict from ._geom import Geometry, _keep_vector from ._sphere import Sphere @@ -145,8 +145,7 @@ def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': return Cylinder(self._center, self.radius * factor, self.depth * factor, self.rotation, self.axis, self.variable_attrs, self.value_attrs) def __getitem__(self, item): - item = slicing_dict(self, item) - return Cylinder(self._center[_keep_vector(item)], self.radius[item], self.depth[item], math.slice(self.rotation, item), self.axis, self.variable_attrs, self.value_attrs) + return getitem_dataclass(self, item, keepdims='vector') @staticmethod def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': @@ -250,6 +249,7 @@ def cylinder(center: Union[Tensor, float] = None, center = center else: center = wrap(tuple(center_.value_attrs()), channel(vector=tuple(center_.keys()))) + assert radius is not None, "radius must be specified" radius = wrap(radius) if depth is None: assert isinstance(axis, Tensor) From 3488e335ccdda9391fff9dfdd005bc4787796098 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 24 Nov 2024 17:44:44 +0100 Subject: [PATCH 54/71] [geom] Fix Point.bounding_half_extent() --- phi/geom/_geom.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/phi/geom/_geom.py b/phi/geom/_geom.py index 0235c97ad..b26faa639 100644 --- a/phi/geom/_geom.py +++ b/phi/geom/_geom.py @@ -1,14 +1,12 @@ -import copy import warnings from numbers import Number from typing import Union, Dict, Any, Tuple, Callable -from phiml.math import instance, non_batch - from phi import math -from phi.math import Tensor, Shape, EMPTY_SHAPE, non_channel, wrap, shape, Extrapolation -from phiml.math._magic_ops import variable_attributes, expand, stack, find_differences +from phi.math import Tensor, Shape, non_channel, wrap, shape, Extrapolation from phi.math.magic import BoundDim, slicing_dict +from phiml.math import non_batch, tensor_like +from phiml.math._magic_ops import variable_attributes, expand, find_differences class Geometry: @@ -699,7 +697,7 @@ def bounding_radius(self) -> Tensor: return math.zeros() def bounding_half_extent(self) -> Tensor: - return expand(0, self._shape) + return tensor_like(self.center, 0) def at(self, center: Tensor) -> 'Geometry': return Point(center) From a94e9c7f99be23da47d98205f30d314d63a2eb16 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 24 Nov 2024 17:45:29 +0100 Subject: [PATCH 55/71] [geom] Refactor Mesh * Make Mesh a dataclass * Vectorize build_faces_2d() using sparse matrices --- phi/field/_resample.py | 8 +- phi/geom/_mesh.py | 843 ++++++++++++++++++++--------------------- 2 files changed, 407 insertions(+), 444 deletions(-) diff --git a/phi/field/_resample.py b/phi/field/_resample.py index ff59fbb8e..57d171c8c 100644 --- a/phi/field/_resample.py +++ b/phi/field/_resample.py @@ -364,7 +364,7 @@ def _shift_resample(self: Field, resolution: Shape, bounds: Box, threshold=1e-5, def centroid_to_faces(u: Field, boundary: Extrapolation, order=2, upwind: Field = None, ignore_skew=False, gradient: Field = None): assert isinstance(upwind, Field) or upwind is None, f"upwind must be a Field but got {type(upwind)}" - if u.mesh._nb in u.values.shape: + if u.mesh.face_shape.dual in u.values.shape: return u.values neighbor_val = u.mesh.pad_boundary(u.values, mode=u.boundary) upwind = upwind.at_faces(extrapolation.NONE, order=2, upwind=None) if upwind is not None else None @@ -387,13 +387,13 @@ def centroid_to_faces(u: Field, boundary: Extrapolation, order=2, upwind: Field relative_face_distance = slice_off_constant_faces(u.mesh.relative_face_distance, u.mesh.boundary_faces, boundary) return (1 - relative_face_distance) * u.values + relative_face_distance * neighbor_val else: # skew correction - nb_center = math.replace_dims(u.mesh.center, 'cells', u.mesh._nb) + nb_center = math.replace_dims(u.mesh.center, 'cells', u.mesh.face_shape.dual) cell_deltas = math.pairwise_distances(u.mesh.center, format=u.mesh.cell_connectivity, default=None) # x_N - x_P face_distance = nb_center - u.mesh.face_centers[u.mesh.interior_faces] # x_N - x_f # face_distance = u.mesh.face_centers[u.mesh.interior_faces] - u.mesh.center # x_f - x_P normals = u.mesh.face_normals[u.mesh.interior_faces] w_interior = (face_distance.vector @ normals.vector) / (cell_deltas.vector @ normals.vector) # n·(x_N - x_f) / n·(x_N - x_P) - w = math.concat([w_interior, 0 * u.mesh.boundary_connectivity], u.mesh._nb) + w = math.concat([w_interior, 0 * u.mesh.boundary_connectivity], u.mesh.face_shape.dual) w = slice_off_constant_faces(w, u.mesh.boundary_faces, boundary) # first padding, then slicing is inefficient, but usually we don't slice anything off (boundary=none) # w = u.mesh.pad_boundary(w_interior, {k: s for k, s in u.mesh.boundary_faces.items() if not boundary.determines_boundary_values(k)}, boundary) this is only for vectors # b0 = math.tensor_like(slice_off_constant_faces(u.mesh.connectivity, u.mesh.boundary_faces, boundary), 0) @@ -407,7 +407,7 @@ def sample_mesh(f: Field, gradient: Union[str, Field] = 'green-gauss', order=2, max_steps=None): # at least 2 to resolve locations outside the mesh - max_steps = f.mesh._max_cell_walk if max_steps is None else max_steps + max_steps = f.mesh.max_cell_walk if max_steps is None else max_steps idx = math.find_closest(f.center, location) for i in range(max_steps): idx, leaves_mesh, is_outside, *_ = f.mesh.cell_walk_towards(location, idx, allow_exit=i == max_steps - 1) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index c64a3f9da..f1a233a66 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -1,134 +1,142 @@ import os import warnings +from dataclasses import dataclass +from functools import cached_property from numbers import Number from typing import Dict, List, Sequence, Union, Any, Tuple, Optional import numpy as np from scipy.sparse import csr_matrix, coo_matrix -from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d +from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d, non_dual, nonzero, stored_indices, stored_values, scatter, \ + find_closest, sqrt, where, vec_normalize, argmax, broadcast, to_int32, cross_product, zeros, random_normal, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ + assert_close, shift, pad, extrapolation, NUMPY, sum as sum_, with_diagonal, flatten, ones_like, dim_mask +from phiml.math._magic_ops import getitem_dataclass from phiml.math._sparse import CompactSparseTensor -from phiml.math.extrapolation import as_extrapolation +from phiml.math.extrapolation import as_extrapolation, PERIODIC from phiml.math.magic import slicing_dict +from . import bounding_box from ._functions import plane_sgn_dist from ._geom import Geometry, Point, scale, NoGeometry from ._box import Box, BaseBox from ._graph import Graph, graph -from .. import math from ..math import Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, \ pairwise_distances, concat, Extrapolation -class Mesh(Geometry): - """ - Unstructured mesh. - Use `phi.geom.mesh()` or `phi.geom.mesh_from_numpy()` to construct a mesh manually or `phi.geom.load_su2()` to load one from a file. - """ - - def __init__(self, +class _MeshType(type): + """Metaclass containing the user-friendly (legacy) Mesh constructor.""" + def __call__(cls, vertices: Union[Geometry, Tensor], elements: Tensor, element_rank: int, boundaries: Dict[str, Dict[str, slice]], - center: Tensor, - volume: Tensor, - normals: Optional[Tensor], - face_centers: Optional[Tensor], - face_normals: Optional[Tensor], - face_areas: Optional[Tensor], - face_vertices: Optional[Tensor], - vertex_normals: Optional[Tensor], - vertex_connectivity: Optional[Tensor], - element_connectivity: Optional[Tensor], - max_cell_walk: int = None): - """ - Args: - vertices: Vertex positions, shape (vertices:i, vector:c) - elements: Sparse `Tensor` listing ordered vertex indices per cell. (cells, ~vertices). - The vertex count is equal to the number of elements per row. - face_vertices: (cells, ~cells, face_vertices) - """ - assert elements.dtype.kind == int, f"elements must be integer lists but got dtype {elements.dtype}" - assert isinstance(center, Tensor), f"center must be a Tensor" + max_cell_walk: int = None, + variables=('vertices',), + values=()): + if spatial(elements): + assert elements.dtype.kind == int, f"elements listing vertices must be integer lists but got dtype {elements.dtype}" + else: + assert elements.dtype.kind == bool, f"element matrices must be of type bool but got {elements.dtype}" if not isinstance(vertices, Geometry): vertices = Point(vertices) - self._vertices = vertices - self._elements = elements - self._element_rank = element_rank - self._boundaries = boundaries - self._center = center - self._volume = volume - self._face_centers = face_centers - self._face_normals = face_normals - self._face_areas = face_areas - if self._face_areas is not None: - assert set(face_areas.shape.names) == set((instance(elements) & dual).names), f"face_areas must have matching primal and dual dims matching elements {instance(elements)} but got {face_areas.shape}" - self._face_vertices = face_vertices - assert normals is None or (isinstance(normals, Tensor) and instance(center) in normals) - self._normals = normals - if vertex_connectivity is None and isinstance(vertices, Graph): - self._vertex_connectivity = vertices.connectivity - else: - assert vertex_connectivity is None or (isinstance(vertex_connectivity, Tensor) and instance(self._vertices) in vertex_connectivity.shape), f"Illegal vertex connectivity: {vertex_connectivity}" - self._vertex_connectivity = vertex_connectivity - assert vertex_normals is None or (dual(vertex_normals).rank == 1 and instance(vertex_normals).rank == 0) - self._vertex_normals = vertex_normals - assert element_connectivity is None or isinstance(element_connectivity, Tensor), f"element_connectivity must be a Tensor" - self._element_connectivity = element_connectivity - if face_areas is not None or face_centers is not None or face_normals is not None: - cell_deltas = pairwise_distances(self.center, format=self.cell_connectivity) - cell_distances = math.vec_length(cell_deltas) - neighbors_dim = dual(face_areas) - assert (cell_distances > 0).all, f"All cells must have distance > 0 but found 0 distance at {math.nonzero(cell_distances == 0)}" - face_distances = math.vec_length(self.face_centers[self.interior_faces] - self.center) - self._relative_face_distance = math.concat([face_distances / cell_distances, self.boundary_connectivity], neighbors_dim) - boundary_deltas = (self.face_centers - self.center)[self.all_boundary_faces] - assert (math.vec_length(boundary_deltas) > 0).all, f"All boundary faces must be separated from the cell centers but 0 distance at the following {channel(math.stored_indices(boundary_deltas)).item_names[0]}:\n{math.nonzero(math.vec_length(boundary_deltas) == 0):full}" - self._neighbor_offsets = math.concat([cell_deltas, boundary_deltas], neighbors_dim) - else: - self._relative_face_distance = None - self._neighbor_offsets = None if max_cell_walk is None: max_cell_walk = 2 if instance(elements).volume > 1 else 1 - self._max_cell_walk = max_cell_walk + result = cls.__new__(cls, vertices, elements, element_rank, boundaries, max_cell_walk, variables, values) + result.__init__(vertices, elements, element_rank, boundaries, max_cell_walk, variables, values) # also calls __post_init__() + return result - def __variable_attrs__(self): - return '_vertices', '_elements', '_center', '_volume', '_face_centers', '_face_normals', '_face_areas', '_face_vertices', '_normals', '_vertex_connectivity', '_vertex_normals', '_element_connectivity', '_relative_face_distance', '_neighbor_offsets' - def __value_attrs__(self): - return '_vertices', +@dataclass(frozen=True) +class Mesh(Geometry, metaclass=_MeshType): + """ + Unstructured mesh, consisting of vertices and elements. + + Use `phi.geom.mesh()` or `phi.geom.mesh_from_numpy()` to construct a mesh manually or `phi.geom.load_su2()` to load one from a file. + """ - @property + vertices: Geometry + """ Vertices are represented by a `Geometry` instance with an instance dim. """ + elements: Tensor + """ elements: Sparse `Tensor` listing ordered vertex indices per element (solid or surface element, depending on `element_rank`). + Must have one instance dim listing the elements and the corresponding dual dim to `vertices`. + The vertex count of an element is equal to the number of elements in that row (i.e. summing the dual dim). """ + element_rank: int + """The spatial rank of the elements. Solid elements have the same as the ambient space, faces one less.""" + boundaries: Dict[str, Dict[str, slice]] + """Slices to retrieve boundary face values.""" + periodic: Sequence[str] + """List of axis names that are periodic. Periodic boundaries must be named as axis- and axis+. For example `['x']` will connect the boundaries x- and x+.""" + face_format: str = 'csc' + """Sparse matrix format for storing quantities that depend on a pair of neighboring elements, e.g. `face_area`, `face_normal`, `face_center`.""" + max_cell_walk: int = None + + variable_attrs: Tuple[str, ...] = ('vertices',) + value_attrs: Tuple[str, ...] = () + + @cached_property def shape(self) -> Shape: - return shape(self._elements).non_dual & channel(self._vertices) & batch(self._vertices) + return non_dual(self.elements) & channel(self.vertices) & batch(self.vertices) - @property + @cached_property def cell_count(self): - return instance(self._elements).size + return instance(self.elements).size - @property + @cached_property def center(self) -> Tensor: - return self._center - - @property + if self.element_rank == self.spatial_rank: # Compute volumetric center from faces + return sum_(self.face_centers * self.face_areas, dual) / sum_(self.face_areas, dual) + else: # approximate center from vertices + return self._vertex_mean + + @cached_property + def _vertex_mean(self): + """Mean vertex location per element.""" + vertex_count = sum_(self.elements, instance(self.vertices).as_dual()) + return (self.elements @ self.vertices.center) / vertex_count + + @cached_property def face_centers(self) -> Tensor: - return self._face_centers + return self._faces['center'] @property def face_areas(self) -> Tensor: - return self._face_areas + return self._faces['area'] - @property + @cached_property def face_normals(self) -> Tensor: - return self._face_normals + if self.element_rank == self.spatial_rank: # this cannot depend on element centers because that depends on the normals. + normals = self._faces['normal'] + face_centers = self._faces['center'] + normals_out = normals.vector * (face_centers - self._vertex_mean).vector > 0 + normals = where(normals_out, normals, -normals) + return normals + raise NotImplementedError + + @cached_property + def _faces(self) -> Dict[str, Tensor]: + if self.element_rank == 2: + centers, normals, areas, boundary_slices, vertex_connectivity = build_faces_2d(self.vertices.center, self.elements, self.boundaries, self.periodic, self._vertex_mean, self.face_format) + return { + 'center': centers, + 'normal': normals, + 'area': areas, + 'boundary_slices': boundary_slices, + 'vertex_connectivity': vertex_connectivity, + } + return None @property def face_shape(self) -> Shape: - return instance(self._elements) & dual + return instance(self.elements) & dual @property def sets(self): - return {'center': non_batch(self)-'vector', 'vertex': instance(self._vertices), '~vertex': dual(self._elements)} + return { + 'center': non_batch(self)-'vector', + 'vertex': instance(self.vertices), + '~vertex': dual(self.elements) + } def get_points(self, set_key: str) -> Tensor: if set_key == 'vertex': @@ -149,24 +157,20 @@ def boundary_elements(self) -> Dict[str, Dict[str, slice]]: @property def boundary_faces(self) -> Dict[str, Dict[str, slice]]: - return self._boundaries - - @property - def _nb(self): - return dual(self._face_areas) + return self._faces['boundary_slices'] @property def all_boundary_faces(self) -> Dict[str, slice]: - return {self._nb: slice(instance(self).volume, None)} + return {self.face_shape.dual.name: slice(instance(self).volume, None)} @property def interior_faces(self) -> Dict[str, slice]: - return {self._nb: slice(0, instance(self).volume)} + return {self.face_shape.dual.name: slice(0, instance(self).volume)} def pad_boundary(self, value: Tensor, widths: Dict[str, Dict[str, slice]] = None, mode: Extrapolation or Tensor or Number = 0, **kwargs) -> Tensor: mode = as_extrapolation(mode) - if self._nb not in value.shape: - value = math.replace_dims(value, instance, self._nb) + if self.face_shape.dual.name not in value.shape: + value = rename_dims(value, instance, self.face_shape.dual) else: raise NotImplementedError if widths is None: @@ -186,7 +190,7 @@ def pad_boundary(self, value: Tensor, widths: Dict[str, Dict[str, slice]] = None ordered_pieces = [values[i] for i in perm] return concat(ordered_pieces, dim, expand_values=True) - @property + @cached_property def cell_connectivity(self) -> Tensor: """ Returns a bool-like matrix whose non-zero entries denote connected elements. @@ -197,45 +201,45 @@ def cell_connectivity(self) -> Tensor: """ return self.connectivity[self.interior_faces] - @property + @cached_property def boundary_connectivity(self) -> Tensor: return self.connectivity[self.all_boundary_faces] - @property - def connectivity(self) -> Tensor: - if self._element_connectivity is not None: - return self._element_connectivity - if self._face_areas is None and self._face_normals is None and self._face_centers is None: - return None - if is_sparse(self._face_areas): - return tensor_like(self._face_areas, True) - else: - return self._face_areas > 0 - - @property + @cached_property def distance_matrix(self): - return math.vec_length(math.pairwise_distances(self.center, edges=self.cell_connectivity, format='as edges', default=None)) + return vec_length(pairwise_distances(self.center, edges=self.cell_connectivity, format='as edges', default=None)) def faces_to_vertices(self, values: Tensor, reduce=sum): - v = math.stored_values(values, invalid='keep') # ToDo replace this once PhiML has support for dense instance dims and sparse scatter - i = math.stored_values(self._face_vertices, invalid='keep') + v = stored_values(values, invalid='keep') # ToDo replace this once PhiML has support for dense instance dims and sparse scatter + i = stored_values(self.face_vertices, invalid='keep') i = rename_dims(i, channel, instance) - out_shape = non_channel(self._vertices) & shape(values).without(self.face_shape) - return math.scatter(out_shape, i, v, mode=reduce, outside_handling='undefined') + out_shape = non_channel(self.vertices) & shape(values).without(self.face_shape) + return scatter(out_shape, i, v, mode=reduce, outside_handling='undefined') - @property + @cached_property + def _cell_deltas(self): + bounds = bounding_box(self.vertices) + is_periodic = dim_mask(self.vector.item_names, self.periodic) + return pairwise_distances(self.center, format=self.cell_connectivity, periodic=is_periodic, domain=(bounds.lower, bounds.upper)) + + @cached_property def relative_face_distance(self): """|face_center - center| / |neighbor_center - center|""" - return self._relative_face_distance + cell_distances = vec_length(self._cell_deltas) + assert (cell_distances > 0).all, f"All cells must have distance > 0 but found 0 distance at {nonzero(cell_distances == 0)}" + face_distances = vec_length(self.face_centers[self.interior_faces] - self.center) + return concat([face_distances / cell_distances, self.boundary_connectivity], self.face_shape.dual) - @property + @cached_property def neighbor_offsets(self): """Returns shift vector to neighbor centroids and boundary faces.""" - return self._neighbor_offsets + boundary_deltas = (self.face_centers - self.center)[self.all_boundary_faces] + assert (vec_length(boundary_deltas) > 0).all, f"All boundary faces must be separated from the cell centers but 0 distance at the following {channel(stored_indices(boundary_deltas)).item_names[0]}:\n{nonzero(vec_length(boundary_deltas) == 0):full}" + return concat([self._cell_deltas, boundary_deltas], self.face_shape.dual) - @property + @cached_property def neighbor_distances(self): - return vec_length(self._neighbor_offsets) + return vec_length(self.neighbor_offsets) @property def faces(self) -> 'Geometry': @@ -255,108 +259,138 @@ def faces(self) -> 'Geometry': return Point(self.face_centers) @property - def vertices(self) -> Geometry: - return self._vertices - - @property - def vertex_connectivity(self) -> Tensor: - return self._vertex_connectivity + def connectivity(self) -> Tensor: + return self.element_connectivity - @property + @cached_property def element_connectivity(self) -> Tensor: - return self._element_connectivity + if self.element_rank == self.spatial_rank: + if is_sparse(self.face_areas): + return tensor_like(self.face_areas, True) + else: + return self.face_areas > 0 + else: # fallback with no boundaries + coo = to_format(self.elements, 'coo').numpy() + connected_elements = coo @ coo.T + connected_elements.data = np.ones_like(connected_elements.data) + element_connectivity = wrap(connected_elements, instance(self.elements), instance(self.elements).as_dual()) + return element_connectivity + + @cached_property + def vertex_connectivity(self) -> Tensor: + if isinstance(self.vertices, Graph): + return self.vertices.connectivity + if self.element_rank == self.spatial_rank: + return self._faces['vertex_connectivity'] + elif self.element_rank <= 2: + coo = to_format(self.elements, 'coo').numpy() + connected_points = coo.T @ coo # ToDo this also counts vertices not connected by a single line/face as long as they are part of the same element + if not np.all(connected_points.sum_(axis=1) > 0): + warnings.warn("some vertices have no element connection at all", RuntimeWarning) + connected_points.data = np.ones_like(connected_points.data) + vertex_connectivity = wrap(connected_points, instance(self.vertices), dual(self.elements)) + return vertex_connectivity + raise NotImplementedError @property def vertex_graph(self) -> Graph: - if isinstance(self._vertices, Graph): - return self._vertices + if isinstance(self.vertices, Graph): + return self.vertices assert self._vertex_connectivity is not None, f"vertex_graph not available because vertex_connectivity has not been computed" - return graph(self._vertices, self._vertex_connectivity) + return graph(self.vertices, self._vertex_connectivity) def filter_unused_vertices(self) -> 'Mesh': - coo = math.to_format(self._elements, 'coo').numpy() - has_element = np.asarray(coo.sum(0) > 0)[0] - new_index = np.cumsum(has_element) - 1 - new_index_t = wrap(new_index, dual(self._elements)) - has_element = wrap(has_element, instance(self._vertices)) + coo = to_format(self.elements, 'coo').numpy() + has_element = np.asarray(coo.sum_(0) > 0)[0] + new_index = np.cumsum_(has_element) - 1 + new_index_t = wrap(new_index, dual(self.elements)) + has_element = wrap(has_element, instance(self.vertices)) has_element_d = si2d(has_element) - vertices = self._vertices[has_element] + vertices = self.vertices[has_element] v_normals = self._vertex_normals[has_element_d] vertex_connectivity = None if self._vertex_connectivity is not None: - vertex_connectivity = math.stored_indices(self._vertex_connectivity).index.as_batch() + vertex_connectivity = stored_indices(self._vertex_connectivity).index.as_batch() vertex_connectivity = new_index_t[{dual: vertex_connectivity}].index.as_channel() - vertex_connectivity = math.sparse_tensor(vertex_connectivity, math.stored_values(self._vertex_connectivity), non_batch(self._vertex_connectivity).with_sizes(instance(vertices).size), False) - if isinstance(self._elements, CompactSparseTensor): - indices = new_index_t[{dual: self._elements._indices}] - elements = CompactSparseTensor(indices, self._elements._values, self._elements._compressed_dims.with_size(instance(vertices).volume), self._elements._indices_constant, self._elements._matrix_rank) + vertex_connectivity = sparse_tensor(vertex_connectivity, stored_values(self._vertex_connectivity), non_batch(self._vertex_connectivity).with_sizes(instance(vertices).size), False) + if isinstance(self.elements, CompactSparseTensor): + indices = new_index_t[{dual: self.elements._indices}] + elements = CompactSparseTensor(indices, self.elements._values, self.elements._compressed_dims.with_size(instance(vertices).volume), self.elements._indices_constant, self.elements._matrix_rank) else: - filtered_coo = coo_matrix((coo.data, (coo.row, new_index)), shape=(instance(self._elements).volume, instance(vertices).volume)) # ToDo keep sparse format - elements = wrap(filtered_coo, self._elements.shape.without_sizes()) - return Mesh(vertices, elements, self._element_rank, self._boundaries, self._center, self._volume, self._normals, self._face_centers, self._face_normals, self._face_areas, None, v_normals, vertex_connectivity, self._element_connectivity, self._max_cell_walk) - - @property - def elements(self): - return self._elements - - @property - def polygons(self): - raise NotImplementedError # ToDo return Tensor (elements, vertex_list:spatial) + filtered_coo = coo_matrix((coo.data, (coo.row, new_index)), shape=(instance(self.elements).volume, instance(vertices).volume)) # ToDo keep sparse format + elements = wrap(filtered_coo, self.elements.shape.without_sizes()) + return Mesh(vertices, elements, self.element_rank, self.boundaries, self._center, self._volume, self._normals, self.face_centers, self.face_normals, self.face_areas, None, v_normals, vertex_connectivity, self._element_connectivity, self._max_cell_walk) @property def volume(self) -> Tensor: - return self._volume + if isinstance(self.elements, CompactSparseTensor) and self.element_rank == 2: + if instance(self.vertices).volume > 0: + A, B, C, *_ = unstack(self.vertices.center[self.elements._indices], dual) + cross_area = vec_length(cross_product(B - A, C - A)) + fac = {3: 0.5, 4: 1}[dual(self.elements._indices).size] # tri, quad, ... + return fac * cross_area + else: + return zeros(instance(self.vertices)) # empty mesh + elif self.element_rank == self.spatial_rank: + vol_contributions = (self.face_centers.vector @ self.face_normals.vector) * self.face_areas + return sum_(vol_contributions, dual) / self.spatial_rank + raise NotImplementedError - @property - def element_rank(self): - return self._element_rank @property def normals(self) -> Tensor: - return self._normals + """Extrinsic element normal space. This is a 0D vector for solid elements and 1D for surface elements.""" + if isinstance(self.elements, CompactSparseTensor) and self.element_rank == 2: + corners = self.vertices[self.elements._indices] + assert dual(corners).size == 3, f"signed distance currently only supports triangles" + v1, v2, v3 = unstack(corners, dual) + return vec_normalize(cross_product(v2 - v1, v3 - v1)) + raise NotImplementedError @property def vertex_normals(self) -> Tensor: - return self._vertex_normals # dual dim + v_normals = mean(self.elements * self.normals, instance) # (~vertices,vector) + return vec_normalize(v_normals) @property def vertex_positions(self) -> Tensor: - return si2d(self._vertices.center) # dual dim + """Lists the vertex centers along the corresponding dual dim to `self.vertices.center`.""" + return si2d(self.vertices.center) def lies_inside(self, location: Tensor) -> Tensor: - idx = math.find_closest(self._center, location) + idx = find_closest(self._center, location) for i in range(self._max_cell_walk): idx, leaves_mesh, is_outside, *_ = self.cell_walk_towards(location, idx, allow_exit=i == self._max_cell_walk - 1) return ~(leaves_mesh & is_outside) def approximate_signed_distance(self, location: Union[Tensor, tuple]) -> Tensor: if self.element_rank == 2 and self.spatial_rank == 3: - closest_elem = math.find_closest(self._center, location) + closest_elem = find_closest(self._center, location) center = self._center[closest_elem] normal = self._normals[closest_elem] return plane_sgn_dist(center, normal, location) if self._center is None: raise NotImplementedError("Mesh.approximate_signed_distance only available when faces are built.") - idx = math.find_closest(self._center, location) + idx = find_closest(self._center, location) for i in range(self._max_cell_walk): idx, leaves_mesh, is_outside, distances, nb_idx = self.cell_walk_towards(location, idx, allow_exit=False) - return math.max(distances, dual) + return max(distances, dual) def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: if self.element_rank == 2 and self.spatial_rank == 3: - closest_elem = math.find_closest(self._center, location) + closest_elem = find_closest(self._center, location) center = self._center[closest_elem] normal = self._normals[closest_elem] - face_size = math.sqrt(self._volume) * 4 + face_size = sqrt(self._volume) * 4 size = face_size[closest_elem] sgn_dist = plane_sgn_dist(center, normal, location) delta = center - location # this is not accurate... - outward = math.where(abs(sgn_dist) < size, normal, math.normalize(delta)) + outward = where(abs(sgn_dist) < size, normal, vec_normalize(delta)) return sgn_dist, delta, outward, None, closest_elem - # idx = math.find_closest(self._center, location) + # idx = find_closest(self._center, location) # for i in range(self._max_cell_walk): # idx, leaves_mesh, is_outside, distances, nb_idx = self.cell_walk_towards(location, idx, allow_exit=False) - # sgn_dist = math.max(distances, dual) + # sgn_dist = max(distances, dual) # cell_normals = self.face_normals[idx] # normal = cell_normals[{dual: nb_idx}] # return sgn_dist, delta, normal, offset, face_index @@ -380,26 +414,26 @@ def cell_walk_towards(self, location: Tensor, start_cell_idx: Tensor, allow_exit closest_face_centers = self.face_centers[start_cell_idx] offsets = closest_normals.vector @ closest_face_centers.vector # this dot product could be cashed in the mesh distances = closest_normals.vector @ location.vector - offsets - is_outside = math.any(distances > 0, dual) - nb_idx = math.argmax(distances, dual).index[0] # cell index or boundary face index + is_outside = any(distances > 0, dual) + nb_idx = argmax(distances, dual).index[0] # cell index or boundary face index leaves_mesh = nb_idx >= instance(self).volume - next_idx = math.where(is_outside & (~leaves_mesh | allow_exit), nb_idx, start_cell_idx) + next_idx = where(is_outside & (~leaves_mesh | allow_exit), nb_idx, start_cell_idx) return next_idx, leaves_mesh, is_outside, distances, nb_idx - def sample_uniform(self, *shape: math.Shape) -> Tensor: + def sample_uniform(self, *shape: Shape) -> Tensor: raise NotImplementedError def bounding_radius(self) -> Tensor: - center = self._elements * self.center - vert_pos = rename_dims(self._vertices.center, instance, dual) - dist_to_vert = math.vec_length(vert_pos - center) - max_dist = math.max(dist_to_vert, dual) + center = self.elements * self.center + vert_pos = rename_dims(self.vertices.center, instance, dual) + dist_to_vert = vec_length(vert_pos - center) + max_dist = max(dist_to_vert, dual) return max_dist def bounding_half_extent(self) -> Tensor: - center = self._elements * self.center - vert_pos = rename_dims(self._vertices.center, instance, dual) - max_delta = math.max(abs(vert_pos - center), dual) + center = self.elements * self.center + vert_pos = rename_dims(self.vertices.center, instance, dual) + max_delta = max(abs(vert_pos - center), dual) return max_delta def bounding_box(self) -> 'BaseBox': @@ -407,60 +441,56 @@ def bounding_box(self) -> 'BaseBox': @property def bounds(self): - return Box(math.min(self._vertices.center, instance), math.max(self._vertices.center, instance)) + return Box(min(self.vertices.center, instance), max(self.vertices.center, instance)) def at(self, center: Tensor) -> 'Mesh': - if instance(self._elements) in center.shape: + if instance(self.elements) in center.shape: raise NotImplementedError("Setting Mesh positions only supported for vertices, not elements") - if dual(self._elements) in center.shape: - center = rename_dims(center, dual, instance(self._vertices)) - if instance(self._vertices) in center.shape: - vertices = self._vertices.at(center) - return mesh(vertices, self._elements, self._boundaries, build_faces=self._face_areas is not None) + if dual(self.elements) in center.shape: + center = rename_dims(center, dual, instance(self.vertices)) + if instance(self.vertices) in center.shape: + vertices = self.vertices.at(center) + return mesh(vertices, self.elements, self.boundaries) else: shift = center - self.bounds.center return self.shifted(shift) def shifted(self, delta: Tensor) -> 'Mesh': - if instance(self._elements) in delta.shape: + if instance(self.elements) in delta.shape: raise NotImplementedError("Shifting Mesh positions only supported for vertices, not elements") - if dual(self._elements) in delta.shape: - delta = rename_dims(delta, dual, instance(self._vertices)) - if instance(self._vertices) in delta.shape: - vertices = self._vertices.shifted(delta) - return mesh(vertices, self._elements, self._boundaries, build_faces=self._face_areas is not None) + if dual(self.elements) in delta.shape: + delta = rename_dims(delta, dual, instance(self.vertices)) + if instance(self.vertices) in delta.shape: + vertices = self.vertices.shifted(delta) + return mesh(vertices, self.elements, self.boundaries) else: # shift everything - vertices = self._vertices.shifted(delta) + # ToDo transfer cached properties + vertices = self.vertices.shifted(delta) center = self._center + delta - return Mesh(vertices, self._elements, self._element_rank, self._boundaries, center, self._volume, self._normals, self._face_centers, self._face_normals, self._face_areas, self._face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, self._volume, self._normals, self.face_centers, self.face_normals, self.face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': raise NotImplementedError def scaled(self, factor: float | Tensor) -> 'Geometry': pivot = self.bounds.center - vertices = scale(self._vertices, factor, pivot) + vertices = scale(self.vertices, factor, pivot) center = scale(Point(self._center), factor, pivot).center - volume = self._volume * factor**self._element_rank if self._volume is not None else None + volume = self._volume * factor**self.element_rank if self._volume is not None else None face_areas = None - return Mesh(vertices, self._elements, self._element_rank, self._boundaries, center, volume, self._normals, self._face_centers, self._face_normals, face_areas, self._face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, volume, self._normals, self.face_centers, self.face_normals, face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) def __getitem__(self, item): item: dict = slicing_dict(self, item) - assert not spatial(self._elements).only(tuple(item)), f"Cannot slice vertex lists ('{spatial(self._elements)}') but got slicing dict {item}" - assert not instance(self._vertices).only(tuple(item)), f"Slicing by vertex indices ('{instance(self._vertices)}') not supported but got slicing dict {item}" - cells = instance(self.shape).name - if cells in item and isinstance(item[cells], int): - item[cells] = slice(item[cells], item[cells] + 1) - vertices = self._vertices[item] - polygons = self._elements[item] - s = math.slice - return Mesh(vertices, polygons, self._element_rank, self._boundaries, self._center[item], self._volume[item], s(self._normals, item), - s(self._face_centers, item), s(self._face_normals, item), s(self._face_areas, item), s(self._face_vertices, item), - s(self._vertex_normals, item), s(self._vertex_connectivity, item), s(self._element_connectivity, item), self._max_cell_walk) - - -@math.broadcast + assert not spatial(self.elements).only(tuple(item)), f"Cannot slice vertex lists ('{spatial(self.elements)}') but got slicing dict {item}" + assert not instance(self.vertices).only(tuple(item)), f"Slicing by vertex indices ('{instance(self.vertices)}') not supported but got slicing dict {item}" + return getitem_dataclass(self, item, keepdims=[self.shape.instance.name, 'vector']) + + def __repr__(self): + return Geometry.__repr__(self) + + +@broadcast def load_su2(file_or_mesh: str, cell_dim=instance('cells'), face_format: str = 'csc') -> Mesh: """ Load an unstructured mesh from a `.su2` file. @@ -489,8 +519,8 @@ def load_su2(file_or_mesh: str, cell_dim=instance('cells'), face_format: str = ' return mesh_from_numpy(points, mesh.elements, boundaries, cell_dim=cell_dim, face_format=face_format) -@math.broadcast -def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance('cells'), face_format: str = 'csc'): +@broadcast +def load_gmsh(file: str, boundary_names: Sequence[str] = None, periodic: str = None, cell_dim=instance('cells'), face_format: str = 'csc'): """ Load an unstructured mesh from a `.msh` file. @@ -499,6 +529,7 @@ def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance Args: file: Path to `.su2` file. boundary_names: Boundary identifiers corresponding to the blocks in the file. If not specified, boundaries will be numbered. + periodic: cell_dim: Dimension along which to list the cells. This should be an instance dimension. face_format: Sparse storage format for cell connectivity. @@ -525,17 +556,17 @@ def load_gmsh(file: str, boundary_names: Sequence[str] = None, cell_dim=instance boundaries[boundary] = cell_block.data else: raise AssertionError(f"Illegal cell block of type {cell_block.type} for {dim}D mesh") - return mesh_from_numpy(points, elements, boundaries, cell_dim=cell_dim, face_format=face_format) + return mesh_from_numpy(points, elements, boundaries, periodic=periodic, cell_dim=cell_dim, face_format=face_format) -@math.broadcast +@broadcast def load_stl(file: str, face_dim=instance('faces')): import stl model = stl.mesh.Mesh.from_file(file) points = np.reshape(model.points, (-1, 3)) vertices, indices = np.unique(points, axis=0, return_inverse=True) indices = np.reshape(indices, (-1, 3)) - mesh = mesh_from_numpy(vertices, indices, element_rank=2, build_faces=False, cell_dim=face_dim) + mesh = mesh_from_numpy(vertices, indices, element_rank=2, cell_dim=face_dim) return mesh @@ -543,10 +574,7 @@ def mesh_from_numpy(points: Sequence[Sequence], polygons: Sequence[Sequence], boundaries: str | Dict[str, List[Sequence]] | None = None, element_rank: int = None, - build_faces=True, - build_vertex_connectivity=True, - build_normals = True, - normals=None, + periodic: str = None, cell_dim: Shape = instance('cells'), face_format: str = 'csc') -> Mesh: """ @@ -559,8 +587,6 @@ def mesh_from_numpy(points: Sequence[Sequence], E.g. `[(0, 1, 2)]` denotes a single triangle connecting points 0, 1, and 2. boundaries: An unstructured mesh can have multiple boundaries, each defined by a name `str` and a list of faces, defined by their vertices. The `boundaries` `dict` maps boundary names to a list of edges (point pairs) in 2D and faces (3 or more points) in 3D (not yet supported). - build_faces: Whether to extract face information from the given vertex, polygon and boundary information. - build_vertex_connectivity: Whether to build a connectivity matrix for vertex-vertex connections. cell_dim: Dimension along which to list the cells. This should be an instance dimension. face_format: Storage format for cell connectivity, must be one of `csc`, `coo`, `csr`, `dense`. @@ -569,33 +595,28 @@ def mesh_from_numpy(points: Sequence[Sequence], """ cell_dim = cell_dim.with_size(len(polygons)) points = np.asarray(points) - try: - elements_np = np.stack(polygons).astype(np.int32) - except ValueError: - vertex_count = math.to_int32(wrap([len(e) for e in polygons], cell_dim)) - max_len = vertex_count.max - elements_np = np.zeros((len(polygons), max_len), dtype=np.int32) - 1 - for i, element in enumerate(polygons): - elements_np[i, :len(element)] = element xyz = tuple('xyz'[:points.shape[-1]]) vertices = wrap(points, instance('vertices'), channel(vector=xyz)) - polygons = wrap(elements_np, cell_dim, spatial('vertex_index')) - if normals is not None: - normals = wrap(normals, cell_dim, channel(vector=xyz)) - return mesh(vertices, polygons, boundaries, element_rank=element_rank, build_faces=build_faces, build_vertex_connectivity=build_vertex_connectivity, build_normals=build_normals, face_format=face_format, normals=normals) + try: # if all elements have the same vertex count, we stack them + elements_np = np.stack(polygons).astype(np.int32) + elements = wrap(elements_np, cell_dim, spatial('vertex_index')) + except ValueError: + indices = np.concatenate(polygons) + vertex_count = np.asarray([len(e) for e in polygons]) + ptr = np.pad(np.cumsum(vertex_count), (1, 0)) + mat = csr_matrix((np.ones(indices.shape, dtype=bool), indices, ptr), shape=(len(polygons), len(points))) + elements = wrap(mat, cell_dim, instance(vertices).as_dual()) + return mesh(vertices, elements, boundaries, element_rank, periodic, face_format=face_format) -@math.broadcast(dims=batch) +@broadcast(dims=batch) def mesh(vertices: Geometry | Tensor, elements: Tensor, boundaries: str | Dict[str, List[Sequence]] | None = None, element_rank: int = None, - build_faces=True, - build_vertex_connectivity=True, - build_element_connectivity=True, - build_normals=True, - normals=None, - face_format: str = 'csc'): + periodic: str = None, + face_format: str = 'csc', + max_cell_walk: int = None): """ Create a mesh from vertex positions and vertex lists. @@ -607,8 +628,6 @@ def mesh(vertices: Geometry | Tensor, For multiple boundaries, pass a `dict` mapping group names `str` to lists of faces, defined by their vertices. The last entry can be `None` to group all boundary faces not explicitly listed before. The `boundaries` `dict` maps boundary names to a list of edges (point pairs) in 2D and faces (3 or more points) in 3D (not yet supported). - build_faces: Whether to extract face information from the given vertex, polygon and boundary information. - build_vertex_connectivity: Whether to build a connectivity matrix for vertex-vertex connections. face_format: Storage format for cell connectivity, must be one of `csc`, `coo`, `csr`, `dense`. Returns: @@ -618,171 +637,126 @@ def mesh(vertices: Geometry | Tensor, assert instance(vertices), f"vertices must have an instance dimension listing all vertices of the mesh but got {shape(vertices)}" if not isinstance(vertices, Geometry): vertices = Point(vertices) - if build_faces: - assert boundaries is not None, f"When build_faces=True, boundaries must be specified." if spatial(elements): # all elements have same number of vertices indices: Tensor = rename_dims(elements, spatial, instance(vertices).as_dual()) - values = expand(1, non_batch(indices)) - elements = CompactSparseTensor(indices, values, instance(vertices).as_dual(), instance(elements)) + values = expand(True, non_batch(indices)) + elements = CompactSparseTensor(indices, values, instance(vertices).as_dual(), True) assert instance(vertices).as_dual() in elements.shape, f"elements must have the instance dim of vertices {instance(vertices)} but got {shape(elements)}" if element_rank is None: if vertices.vector.size == 2: element_rank = 2 elif vertices.vector.size == 3: - min_vertices = math.sum(elements, instance(vertices).as_dual()).min + min_vertices = sum_(elements, instance(vertices).as_dual()).min element_rank = 2 if min_vertices <= 4 else 3 # assume tri or quad mesh else: raise ValueError(vertices.vector.size) - vertex_count = math.sum(elements, instance(vertices).as_dual()) - approx_center = (elements @ vertices.center) / vertex_count # --- build faces --- - if build_faces: - if element_rank == 2: - centers, normals, areas, boundary_slices, vertex_connectivity, face_vertices = build_faces_2d(vertices.center, elements, boundaries, face_format) - else: - raise NotImplementedError("Building faces currently only supported for 2D elements. Set build_faces=False to construct mesh") - normals_out = normals.vector * (centers - approx_center).vector > 0 - normals = math.where(normals_out, normals, -normals) - vol_contributions = centers.vector * normals.vector * areas / vertices.vector.size - volume = math.sum(vol_contributions, dual) - cell_centers = math.sum(centers * areas, dual) / math.sum(areas, dual) - return Mesh(vertices, elements, element_rank, boundary_slices, cell_centers, volume, None, centers, normals, areas, face_vertices, None, vertex_connectivity, None) - else: - vertex_connectivity = None - if build_vertex_connectivity: - coo = math.to_format(elements, 'coo').numpy() - connected_points = coo.T @ coo - if not np.all(connected_points.sum(axis=1) > 0): - warnings.warn("some vertices have no element connection at all", RuntimeWarning) - connected_points.data = np.ones_like(connected_points.data) - vertex_connectivity = wrap(connected_points, instance(vertices), dual(elements)) - element_connectivity = None - if build_element_connectivity: - coo = math.to_format(elements, 'coo').numpy() - connected_elements = coo @ coo.T - connected_elements.data = np.ones_like(connected_elements.data) - element_connectivity = wrap(connected_elements, instance(elements), instance(elements).as_dual()) - volume = None - if isinstance(elements, CompactSparseTensor) and element_rank == 2: - if instance(vertices).volume > 0: - A, B, C, *_ = unstack(vertices.center[elements._indices], dual) - cross_area = math.vec_length(math.cross_product(B - A, C - A)) - fac = {3: 0.5, 4: 1}[dual(elements._indices).size] # tri, quad, ... - volume = fac * cross_area - else: - volume = math.zeros(instance(vertices)) - if normals is None and build_normals: - normals = extrinsic_normals(vertices.center, elements) - v_normals = None - if build_normals: - v_normals = vertex_normals(elements, normals) - return Mesh(vertices, elements, element_rank, {}, approx_center, volume, normals, None, None, None, None, v_normals, vertex_connectivity, element_connectivity) - - -def build_faces_2d(vertices: Tensor, - polygons: Tensor, - boundaries: Union[str, Dict[str, List[Sequence]]], + periodic_dims = [] + if periodic is not None: + periodic_dims = [s.strip() for s in periodic.split(',') if s.strip()] + assert all(p in vertices.vector.item_names for p in periodic_dims), f"Periodic boundaries must be named after axes, e.g. {vertices.vector.item_names} but got {periodic}" + for base in periodic_dims: + assert base+'+' in boundaries and base+'-' in boundaries, f"Missing boundaries for periodicity '{base}'. Make sure '{base}+' and '{base}-' are keys in boundaries dict, got {tuple(boundaries)}" + return Mesh(vertices, elements, element_rank, boundaries, periodic_dims, face_format, max_cell_walk) + + +def build_faces_2d(vertices: Tensor, # (vertices:i, vector) + elements: Tensor, # (elements:i, ~vertices) + boundaries: Dict[str, Sequence], # vertex pairs + periodic: Sequence[str], # periodic dim names + vertex_mean: Tensor, face_format: str): - poly_by_face = {} # (v1, v2) -> poly_idx - poly1 = [] - poly2 = [] - points1 = [] - points2 = [] - from phiml.math._sparse import native_matrix - polygon_csr: csr_matrix = native_matrix(math.to_format(polygons, 'csr'), math.NUMPY) - # --- Find neighbor cells --- - for poly_idx in range(instance(polygons).size): - vert_indices = polygon_csr.indices[polygon_csr.indptr[poly_idx]:polygon_csr.indptr[poly_idx+1]] - n_vert = len(vert_indices) - for i in range(n_vert): - v1 = vert_indices[i] - v2 = vert_indices[(i+1) % n_vert] - face = (v1, v2) if v1 < v2 else (v2, v1) - if face in poly_by_face: - other_poly_idx = poly_by_face[face] - del poly_by_face[face] - poly1.append(poly_idx) - poly2.append(other_poly_idx) - points1.append(v1) - points2.append(v2) - else: - poly_by_face[face] = poly_idx - # --- Add boundary faces --- - b_poly1 = [] - b_poly2 = [] - b_points1 = [] - b_points2 = [] - boundary_idx = instance(polygons).size + """ + Given a list of vertices, elements and boundary edges, computes the element connectivity matrix and corresponding edge properties. + + Args: + vertices: `Tensor` representing list (instance) of vectors (channel) + elements: Sparse matrix listing all elements (instance). Each entry represents a vertex (dual) belonging to an element. + boundaries: Named sequences of edges (vertex pairs). + periodic: Which dims are periodic. + vertex_mean: Mean vertex position for each element. + face_format: Sparse matrix format to use for the element-element matrices. + """ + cell_dim = instance(elements).name + nb_dim = instance(elements).as_dual().name + boundaries = {k: wrap(v, 'line:i,vert:i=(start,end)') for k, v in boundaries.items()} + # --- Periodic: map duplicate vertices to the same index --- + vertex_id = np.arange(instance(vertices).size) + for dim in periodic: + lo_idx, up_idx = boundaries[dim+'-'], boundaries[dim+'+'] + for lo_i, up_i in zip(set(flatten(lo_idx)), set(flatten(up_idx))): + vertex_id[up_i] = lo_i # map periodic vertices to one index + el_coo = to_format(elements, 'coo').numpy().astype(np.int32) + el_coo.col = vertex_id[el_coo.col] + # --- Add virtual boundary elements for non-periodic boundaries --- boundary_slices = {} - nb_dim = instance(polygons).as_dual().name - if not isinstance(boundaries, dict): - boundaries = {boundaries: None} - for boundary_name, pair_list in boundaries.items(): - boundary_start_idx = boundary_idx - if pair_list is not None: - for v1, v2 in pair_list: - poly_idx = poly_by_face.get((v1, v2), None) - if poly_idx is None: - poly_idx = poly_by_face.get((v2, v1), None) - assert poly_idx is not None, f"Boundary edge between vertices {v1} and {v2} is not connected to any cell! Either add a connected polygon or remove it from the boundary '{boundary_name}'" - del poly_by_face[(v2, v1)] - b_points1.append(v2) - b_points2.append(v1) - else: - del poly_by_face[(v1, v2)] - b_points1.append(v1) - b_points2.append(v2) - b_poly1.append(poly_idx) - b_poly2.append(boundary_idx) - boundary_idx += 1 - else: # auto-fill rest - for (v1, v2), poly_idx in poly_by_face.items(): - b_points1.append(v1) - b_points2.append(v2) - b_poly1.append(poly_idx) - b_poly2.append(boundary_idx) - boundary_idx += 1 - poly_by_face.clear() - boundary_slices[boundary_name] = {nb_dim: slice(boundary_start_idx, boundary_idx)} - assert not poly_by_face, f"{len(poly_by_face)} edges are not marked and do not have a neighbor cell: {tuple(poly_by_face)}" - neighbor_count = boundary_idx - # --- wrap results as Φ-Flow tensors --- - poly_pairs = np.asarray([poly1 + poly2 + b_poly1, poly2 + poly1 + b_poly2]).T # include transpose of inner faces - face_dim = instance('faces') - indices = wrap(poly_pairs, face_dim, channel(vector=[instance(polygons).name, nb_dim])) - point_idx1 = wrap(points1 + points2 + b_points1, face_dim) - point_idx2 = wrap(points2 + points1 + b_points2, face_dim) - loc_points1 = vertices[{instance: point_idx1}] - loc_points2 = vertices[{instance: point_idx2}] - # --- Compute edge properties --- - delta = loc_points2 - loc_points1 - area = vec_length(delta) - center = (loc_points1 + loc_points2) / 2 - if channel(vertices).size == 2: - normal = stack([-delta[1], delta[0]], channel(vertices)) - normal /= vec_length(normal) - elif channel(vertices).size == 3: # Surface mesh in 3D - warnings.warn("Normals not yet supported for embedded 3D meshes. Using placeholder values.", RuntimeWarning, stacklevel=3) - normal = math.random_normal(instance(delta), channel(vertices)) # ToDo - # --- Faces --- - dual_poly_dim = dual(**{nb_dim: neighbor_count}) - area = sparse_tensor(indices, area, instance(polygons) & dual_poly_dim, format='coo' if face_format == 'dense' else face_format, indices_constant=True) - normal = tensor_like(area, normal, value_order='original') - center = tensor_like(area, center, value_order='original') - face_centers = to_format(center, face_format) - face_normals = to_format(normal, face_format) - face_areas = to_format(area, face_format) - # --- vertex-vertex connectivity --- - vert_pairs = stack([wrap(points1 + points2 + b_points1 + b_points2, instance('edges')), wrap(points2 + points1 + b_points2 + b_points1, instance('edges'))], channel(idx=[non_channel(vertices).name, nb_dim])) - vertex_connectivity = sparse_tensor(vert_pairs, expand(True, instance(vert_pairs)), non_channel(vertices) & dual(**{nb_dim: non_channel(vertices).size}), can_contain_double_entries=False, indices_sorted=False, indices_constant=True) - # --- vertex-face connectivity --- - vertex_pairs = stack([point_idx1, point_idx2], channel('face_vertices')) - face_vertices = tensor_like(area, vertex_pairs, value_order='original') - return face_centers, face_normals, face_areas, boundary_slices, vertex_connectivity, face_vertices + end = instance(elements).size + bnd_coo_idx, bnd_coo_vert = [el_coo.row], [el_coo.col] + for bnd_key, bnd_vertices in boundaries.items(): + if bnd_key[:-1] in periodic: + continue + bnd_vert = bnd_vertices.numpy(['line,vert']) + bnd_idx = np.arange(bnd_vertices.line.size).repeat(2) + end + bnd_coo_idx.append(bnd_idx) + bnd_coo_vert.append(bnd_vert) + boundary_slices[bnd_key] = {nb_dim: slice(end, end+bnd_vertices.line.size)} + end += bnd_vertices.line.size + bnd_coo_idx = np.concatenate(bnd_coo_idx) + bnd_coo_vert = vertex_id[np.concatenate(bnd_coo_vert)] + bnd_el_coo = coo_matrix((np.ones((bnd_coo_idx.size,), dtype=bool), (bnd_coo_idx, bnd_coo_vert)), shape=(end, instance(vertices).size)) + # --- Compute neighbor elements --- + num_shared_vertices: csr_matrix = el_coo @ bnd_el_coo.T + neighbor_filter, = np.where(num_shared_vertices.data == 2) + src_cell, nb_cell = num_shared_vertices.nonzero() + src_cell = src_cell[neighbor_filter] + nb_cell = nb_cell[neighbor_filter] + connected_elements_coo = coo_matrix((np.ones(src_cell.size, dtype=bool), (src_cell, nb_cell)), shape=num_shared_vertices.shape) + element_connectivity = wrap(connected_elements_coo, instance(elements).without_sizes() & dual) + element_connectivity = to_format(element_connectivity, face_format) + # --- Find vertices for each face pair using 4 alternating patterns: [0101...], [1010...], ["]+[010...], [101...]+["] --- + bnd_el_coo_v_idx = coo_matrix((bnd_coo_vert+1, (bnd_coo_idx, bnd_coo_vert)), shape=(end, instance(vertices).size)) + ptr = np.cumsum(np.asarray(el_coo.sum(1))) + first_ptr = np.pad(ptr, (1, 0))[:-1] + last_ptr = ptr - 1 + alt1 = np.arange(el_coo.data.size) % 2 + alt2 = (1 - alt1) + alt2[first_ptr] = alt1[first_ptr] + alt3 = (1 - alt1) + alt3[last_ptr] = alt1[last_ptr] + v_indices = [] + for alt in [alt1, (1-alt1), alt2, alt3]: + el_coo.data = alt + 1e-10 + alt_v_idx = (el_coo @ bnd_el_coo_v_idx.T) + v_indices.append(alt_v_idx.data[neighbor_filter].astype(np.int32)) + v_indices = np.sort(np.stack(v_indices, -1), axis=1) - 1 + # Cases: 0,i1,i2 | i1,i1,i2 | i1,i2,i2 | i1,i2,i1+i2 (0 is invalid, doubles are invalid) + # For [1-3]: If self > left and left != 0 and it is the first -> this is the second element. + first_index = np.argmax((v_indices[:, 1:] > v_indices[:, :-1]) & (v_indices[:, :-1] >= 0), 1) + v_indices = v_indices[np.arange(v_indices.shape[0]), np.stack([first_index, first_index+1])] + v_indices = wrap(v_indices, 'vert:i=(start,end),edge:i') + v_pos = vertices[v_indices] + if periodic: # map v_pos: closest to cell_center + cell_center = vertex_mean[wrap(src_cell, 'edge:i')] + bounds = bounding_box(vertices) + delta = PERIODIC.shortest_distance(cell_center - bounds.lower, v_pos - bounds.lower, bounds.size) + is_periodic = dim_mask(vertices.vector.item_names, periodic) + v_pos = where(is_periodic, cell_center + delta, v_pos) + # --- Compute face information --- + edge_dir = v_pos.vert['end'] - v_pos.vert['start'] + edge_center = .5 * (v_pos.vert['end'] + v_pos.vert['start']) + edge_len = vec_length(edge_dir) + normal = vec_normalize(stack([-edge_dir[1], edge_dir[0]], channel(edge_dir))) + # --- Wrap in sparse matrices --- + indices = wrap(np.stack([src_cell, nb_cell]), channel(index=(cell_dim, nb_dim)), 'edge:i') + edge_len = sparse_tensor(indices, edge_len, element_connectivity.shape, format='coo' if face_format == 'dense' else face_format, indices_constant=True) + normal = tensor_like(edge_len, normal, value_order='original') + edge_center = tensor_like(edge_len, edge_center, value_order='original') + vertex_connectivity = None + return edge_center, normal, edge_len, boundary_slices, vertex_connectivity def build_mesh(bounds: Box = None, - resolution=math.EMPTY_SHAPE, + resolution=EMPTY_SHAPE, obstacles: Union[Geometry, Dict[str, Geometry]] = None, method='quad', cell_dim: Shape = instance('cells'), @@ -816,61 +790,61 @@ def build_mesh(bounds: Box = None, if bounds is None: # **resolution_ specifies points assert not resolution, f"When specifying vertex positions, bounds and resolution will be inferred and must not be specified." resolution = spatial(**{dim: non_batch(x).volume for dim, x in resolution_.items()}) - 1 - vert_pos = math.meshgrid(**resolution_) + vert_pos = meshgrid(**resolution_) bounds = Box(**{dim: (x[0], x[-1]) for dim, x in resolution_.items()}) # centroid_x = {dim: .5 * (wrap(x[:-1]) + wrap(x[1:])) for dim, x in resolution_.items()} - # centroids = math.meshgrid(**centroid_x) + # centroids = meshgrid(**centroid_x) else: # uniform grid from bounds, resolution resolution = resolution & spatial(**resolution_) - vert_pos = math.meshgrid(resolution + 1) / resolution * bounds.size + bounds.lower + vert_pos = meshgrid(resolution + 1) / resolution * bounds.size + bounds.lower # centroids = UniformGrid(resolution, bounds).center dx = bounds.size / resolution - regular_size = math.min(dx, channel) + regular_size = min(dx, channel) vert_pos, polygons, boundaries = build_quadrilaterals(vert_pos, resolution, obstacles, bounds, regular_size * max_squish) if max_squish is not None: lin_vert_pos = pack_dims(vert_pos, spatial, instance('polygon')) corner_pos = lin_vert_pos[polygons] - min_pos = math.min(corner_pos, '~polygon') - max_pos = math.max(corner_pos, '~polygon') - cell_sizes = math.min(max_pos - min_pos, 'vector') + min_pos = min(corner_pos, '~polygon') + max_pos = max(corner_pos, '~polygon') + cell_sizes = min(max_pos - min_pos, 'vector') too_small = cell_sizes < regular_size * max_squish # --- remove too small cells --- removed = polygons[too_small] - removed_centers = math.mean(lin_vert_pos[removed], '~polygon') + removed_centers = mean(lin_vert_pos[removed], '~polygon') kept_vert = removed[{'~polygon': 0}] - vert_pos = math.scatter(lin_vert_pos, kept_vert, removed_centers) - vertex_map = math.range(non_channel(lin_vert_pos)) - vertex_map = math.scatter(vertex_map, rename_dims(removed, '~polygon', instance('poly_list')), expand(kept_vert, instance(poly_list=4))) + vert_pos = scatter(lin_vert_pos, kept_vert, removed_centers) + vertex_map = range(non_channel(lin_vert_pos)) + vertex_map = scatter(vertex_map, rename_dims(removed, '~polygon', instance('poly_list')), expand(kept_vert, instance(poly_list=4))) polygons = polygons[~too_small] polygons = vertex_map[polygons] boundaries = {boundary: vertex_map[edge_list] for boundary, edge_list in boundaries.items()} boundaries = {boundary: edge_list[edge_list[{'~vert': 'start'}] != edge_list[{'~vert': 'end'}]] for boundary, edge_list in boundaries.items()} # ToDo remove edges which now point to the same vertex def build_single_mesh(vert_pos, polygons, boundaries): - points_np = math.reshaped_numpy(vert_pos, [..., channel]) - polygon_list = math.reshaped_numpy(polygons, [..., dual]) + points_np = reshaped_numpy(vert_pos, [..., channel]) + polygon_list = reshaped_numpy(polygons, [..., dual]) boundaries = {b: edges.numpy('edges,~vert') for b, edges in boundaries.items()} return mesh_from_numpy(points_np, polygon_list, boundaries, cell_dim=cell_dim, face_format=face_format) - return math.map(build_single_mesh, vert_pos, polygons, boundaries, dims=batch) + return map(build_single_mesh, vert_pos, polygons, boundaries, dims=batch) def build_quadrilaterals(vert_pos, resolution: Shape, obstacles: Dict[str, Geometry], bounds: Box, min_size) -> Tuple[Tensor, Tensor, dict]: - vert_id = math.range_tensor(resolution + 1) + vert_id = range_tensor(resolution + 1) # --- obstacles: mask and boundaries --- boundaries = {} full_mask = expand(False, resolution) for boundary, obstacle in obstacles.items(): assert isinstance(obstacle, Geometry), f"all obstacles must be Geometry objects but got {type(obstacle)}" active_mask_vert = obstacle.approximate_signed_distance(vert_pos) > min_size - obs_mask_cell = math.convolve(active_mask_vert, expand(1, resolution.with_sizes(2))) == 0 # use all cells with one non-blocked vertex - math.assert_close(False, obs_mask_cell & full_mask, msg="Obstacles must not overlap. For overlapping obstacles, use union() to assign a single boundary.") - lo, up = math.shift(obs_mask_cell, (0, 1), padding=None) + obs_mask_cell = convolve(active_mask_vert, expand(1, resolution.with_sizes(2))) == 0 # use all cells with one non-blocked vertex + assert_close(False, obs_mask_cell & full_mask, msg="Obstacles must not overlap. For overlapping obstacles, use union() to assign a single boundary.") + lo, up = shift(obs_mask_cell, (0, 1), padding=None) face_mask = lo != up for dim, dim_mask in dict(**face_mask.shift).items(): face_verts = vert_id[{dim: slice(1, -1)}] start_vert = face_verts[{d: slice(None, -1) for d in resolution.names if d != dim}] end_vert = face_verts[{d: slice(1, None) for d in resolution.names if d != dim}] - mask_indices = math.nonzero(face_mask.shift[dim], list_dim=instance('edges')) + mask_indices = nonzero(face_mask.shift[dim], list_dim=instance('edges')) edges = stack([start_vert[mask_indices], end_vert[mask_indices]], dual(vert='start,end')) boundaries.setdefault(boundary, []).append(edges) # edge_list = [(s, e) for s, e, m in zip(start_vert, end_vert, dim_mask) if m] @@ -880,7 +854,7 @@ def build_quadrilaterals(vert_pos, resolution: Shape, obstacles: Dict[str, Geome # --- outer boundaries --- def all_faces(ids: Tensor, edge_mask: Tensor, dim): assert ids.rank == 1 - mask_indices = math.nonzero(~edge_mask, list_dim=instance('edges')) + mask_indices = nonzero(~edge_mask, list_dim=instance('edges')) start_vert = ids[:-1] end_vert = ids[1:] return stack([start_vert[mask_indices], end_vert[mask_indices]], dual(vert='start,end')) @@ -889,7 +863,7 @@ def all_faces(ids: Tensor, edge_mask: Tensor, dim): boundaries[dim+'-'] = all_faces(vert_id[{dim: 0}], full_mask[{dim: 0}], dim) boundaries[dim+'+'] = all_faces(vert_id[{dim: -1}], full_mask[{dim: -1}], dim) # --- cells --- - cell_indices = math.nonzero(~full_mask) + cell_indices = nonzero(~full_mask) if resolution.rank == 2: d1, d2 = resolution.names c1 = vert_id[{d1: slice(0, -1), d2: slice(0, -1)}] @@ -901,32 +875,21 @@ def all_faces(ids: Tensor, edge_mask: Tensor, dim): else: raise NotImplementedError(resolution.rank) # --- push vertices out of obstacles --- - ext_mask = math.pad(~full_mask, {d: (0, 1) for d in resolution.names}, False) - has_cell = math.convolve(ext_mask, expand(1, resolution.with_sizes(2)), math.extrapolation.ZERO) # vertices without a cell could be removed to improve memory/cache efficiency + ext_mask = pad(~full_mask, {d: (0, 1) for d in resolution.names}, False) + has_cell = convolve(ext_mask, expand(1, resolution.with_sizes(2)), extrapolation.ZERO) # vertices without a cell could be removed to improve memory/cache efficiency for obstacle in obstacles.values(): shifted_verts = obstacle.push(vert_pos) - vert_pos = math.where(has_cell, shifted_verts, vert_pos) + vert_pos = where(has_cell, shifted_verts, vert_pos) vert_pos = bounds.push(vert_pos, outward=False) return vert_pos, polygons, boundaries def tri_points(mesh: Mesh): - corners = mesh.vertices.center[mesh._elements._indices] + corners = mesh.vertices.center[mesh.elements._indices] assert dual(corners).size == 3, f"signed distance currently only supports triangles" return unstack(corners, dual) -def extrinsic_normals(vertices: Tensor, elements: Tensor): - corners = vertices[elements._indices] - assert dual(corners).size == 3, f"signed distance currently only supports triangles" - A, B, C = unstack(corners, dual) - return math.vec_normalize(math.cross_product(B-A, C-A)) - - -def vertex_normals(elements: Tensor, face_normals: Tensor): - v_normals = math.mean(elements * face_normals, instance) # (~vertices,vector) - return math.vec_normalize(v_normals) - def face_curvature(mesh: Mesh): v_normals = mesh.elements * si2d(mesh.vertex_normals) @@ -939,15 +902,15 @@ def face_curvature(mesh: Mesh): n1, n2, n3 = unstack(v_normals._values, dual) dn1, dn2, dn3 = n2-n1, n3-n2, n1-n3 curvature_tensor = .5 / mesh.volume * (e1 * dn1 + e2 * dn2 + e3 * dn3) - scalar_curvature = math.sum([curvature_tensor[{'vector': d, '~vector': d}] for d in mesh.vector.item_names], '0') + scalar_curvature = sum_([curvature_tensor[{'vector': d, '~vector': d}] for d in mesh.vector.item_names], '0') return curvature_tensor, scalar_curvature - # vec_curvature = math.max(v_normals, dual) - math.min(v_normals, dual) # positive / negative + # vec_curvature = max(v_normals, dual) - min(v_normals, dual) # positive / negative def save_tri_mesh(file: str, mesh: Mesh, **extra_data): - v = math.reshaped_numpy(mesh.vertices.center, [instance, 'vector']) - if isinstance(mesh._elements, CompactSparseTensor): - f = math.reshaped_numpy(mesh._elements._indices, [instance, dual]) + v = reshaped_numpy(mesh.vertices.center, [instance, 'vector']) + if isinstance(mesh.elements, CompactSparseTensor): + f = reshaped_numpy(mesh.elements._indices, [instance, dual]) else: raise NotImplementedError print(f"Saving triangle mesh with {v.shape[0]} vertices and {f.shape[0]} faces to {file}") @@ -955,15 +918,15 @@ def save_tri_mesh(file: str, mesh: Mesh, **extra_data): np.savez(file, vertices=v, faces=f, f_dim=instance(mesh).name, vertex_dim=instance(mesh.vertices).name, vector=mesh.vector.item_names, has_extra_data=bool(extra_data), **extra_data) -@math.broadcast -def load_tri_mesh(file: str, convert=False, load_extra=(), build_vertex_connectivity=True, build_normals=True, build_element_connectivity=True) -> Mesh | Tuple[Mesh, ...]: +@broadcast +def load_tri_mesh(file: str, convert=False, load_extra=()) -> Mesh | Tuple[Mesh, ...]: data = np.load(file, allow_pickle=bool(load_extra)) f_dim = instance(str(data['f_dim'])) vertex_dim = instance(str(data['vertex_dim'])) vector = channel(vector=[str(d) for d in data['vector']]) faces = tensor(data['faces'], f_dim, spatial('vertex_list'), convert=convert) vertices = tensor(data['vertices'], vertex_dim, vector, convert=convert) - m = mesh(vertices, faces, build_faces=False, build_vertex_connectivity=build_vertex_connectivity, build_normals=build_normals, build_element_connectivity=build_element_connectivity) + m = mesh(vertices, faces) if not load_extra: return m extra = [data[e] for e in load_extra] @@ -971,7 +934,7 @@ def load_tri_mesh(file: str, convert=False, load_extra=(), build_vertex_connecti return m, *extra -@math.broadcast(dims=batch) +@broadcast(dims=batch) def decimate_tri_mesh(mesh: Mesh, factor=.1, target_max=10_000,): if isinstance(mesh, NoGeometry): return mesh @@ -979,10 +942,10 @@ def decimate_tri_mesh(mesh: Mesh, factor=.1, target_max=10_000,): return mesh import pyfqmr mesh_simplifier = pyfqmr.Simplify() - vertices = math.reshaped_numpy(mesh.vertices.center, [instance, 'vector']) - faces = math.reshaped_numpy(mesh.elements._indices, [instance, dual]) + vertices = reshaped_numpy(mesh.vertices.center, [instance, 'vector']) + faces = reshaped_numpy(mesh.elements._indices, [instance, dual]) target_count = min(target_max, int(round(instance(mesh).volume * factor))) mesh_simplifier.setMesh(vertices, faces) mesh_simplifier.simplify_mesh(target_count=target_count, aggressiveness=7, preserve_border=False) vertices, faces, normals = mesh_simplifier.getMesh() - return mesh_from_numpy(vertices, faces, normals=normals, build_faces=False, build_vertex_connectivity=mesh._vertex_connectivity is not None, cell_dim=instance(mesh)) + return mesh_from_numpy(vertices, faces, cell_dim=instance(mesh)) From 9489992de557973884e5c8a74647868ee68714c3 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 24 Nov 2024 18:08:20 +0100 Subject: [PATCH 56/71] [vis] Fix plotting sparse points --- phi/vis/_vis_base.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/phi/vis/_vis_base.py b/phi/vis/_vis_base.py index 892c7bc1a..6a51b4fc7 100644 --- a/phi/vis/_vis_base.py +++ b/phi/vis/_vis_base.py @@ -11,7 +11,7 @@ from phi.field._field_math import data_bounds from phi.geom import Box, Cuboid, Geometry, Point from phi.math import Shape, EMPTY_SHAPE, Tensor, spatial, instance, wrap, channel, expand, non_batch -from phiml.math import vec, concat +from phiml.math import vec, concat, tensor_like Control = namedtuple('Control', [ 'name', @@ -588,13 +588,18 @@ def _limits(center: Tensor, half: Tensor, is_log: Union[bool, Tensor]): def only_stored_elements(f: Field) -> Field: - if not math.is_sparse(f.points): + if not math.is_sparse(f.points) and not math.is_sparse(f.values): return f - elements = f.sampled_elements.at(f.points._values) + if math.is_sparse(f.points): + points = math.stored_values(f.points) + else: + mat = tensor_like(f.values, True) + points = math.stored_values(f.points * mat) + elements = f.sampled_elements.at(points) if math.is_sparse(f.values): - values = f.values._values + values = math.stored_values(f.values) else: - values = f.values[f.points._indices] + values = f.values[math.stored_indices(f.points)] return Field(elements, values, math.extrapolation.NONE) From bf9025f5428d74fdaceeaf56db57471613ae8c45 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Sun, 24 Nov 2024 22:12:22 +0100 Subject: [PATCH 57/71] [geom] Generalized transformation functions (previously in PhiML) --- phi/field/_resample.py | 10 +- phi/flow.py | 4 +- phi/geom/__init__.py | 11 +- phi/geom/_box.py | 11 +- phi/geom/_cylinder.py | 27 +- phi/geom/_embed.py | 158 ++++++++++++ phi/geom/_functions.py | 2 + phi/geom/_geom.py | 48 +--- phi/geom/_geom_functions.py | 23 +- phi/geom/_geom_ops.py | 3 +- phi/geom/_mesh.py | 78 +++--- phi/geom/_transform.py | 316 +++++++++++++---------- phi/vis/_matplotlib/_matplotlib_plots.py | 6 +- tests/commit/geom/test__transform.py | 43 +++ 14 files changed, 484 insertions(+), 256 deletions(-) create mode 100644 phi/geom/_embed.py create mode 100644 tests/commit/geom/test__transform.py diff --git a/phi/field/_resample.py b/phi/field/_resample.py index 57d171c8c..1b252baa5 100644 --- a/phi/field/_resample.py +++ b/phi/field/_resample.py @@ -196,16 +196,16 @@ def scatter_to_centers(self: Field, geometry: Geometry, soft=False, scatter=Fals assert not soft, "Cannot soft-sample when scatter=True" return grid_scatter(self, geometry.bounds, geometry.resolution, outside_handling) else: - assert not isinstance(self._geometry, Point), "Cannot sample Point-like elements with scatter=False" - if may_vary_along(self._values, instance(self._values) & spatial(self._values)): + assert not isinstance(self.geometry, Point), "Cannot sample Point-like elements with scatter=False" + if may_vary_along(self.values, instance(self.values) & spatial(self.values)): raise NotImplementedError("Non-scatter resampling not yet supported for varying values") - idx0 = (instance(self._values) & spatial(self._values)).first_index() + idx0 = (instance(self.values) & spatial(self.values)).first_index() outside = self.boundary.value if isinstance(self.boundary, ConstantExtrapolation) else 0 if soft: frac_inside = self.geometry.approximate_fraction_inside(geometry, balance) - return frac_inside * self._values[idx0] + (1 - frac_inside) * outside + return frac_inside * self.values[idx0] + (1 - frac_inside) * outside else: - return math.where(self.geometry.lies_inside(geometry.center), self._values[idx0], outside) + return math.where(self.geometry.lies_inside(geometry.center), self.values[idx0], outside) def scatter_to_faces(field: Field, geometry: Geometry, extrapolation: Extrapolation, **kwargs) -> Tensor: diff --git a/phi/flow.py b/phi/flow.py index 1ed7476a9..eedd2d388 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -40,12 +40,12 @@ dsum, isum, ssum, csum, mean, dmean, imean, smean, cmean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, scatter, gather, where, nonzero, - rotate_vector as rotate, cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math + cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math safe_div, length, is_finite, is_nan, is_inf, # Basic functions jit_compile, jit_compile_linear, minimize, gradient as functional_gradient, gradient, solve_linear, solve_nonlinear, iterate, identity, # jacobian, hessian, custom_gradient # Functional magic assert_close, always_close, equal, close ) -from .geom import union +from .geom import union, rotate, scale from .vis import show, control, plot # Exceptions diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index 0b32be2f1..574ccafe0 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -10,18 +10,25 @@ See the `phi.geom` module documentation at https://tum-pbs.github.io/PhiFlow/Geometry.html """ from ..math import stack, concat, pack_dims # for compatibility + +# --- Low-level functions --- +from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, sample_function from ._functions import normal_from_slope -from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, rotate, sample_function +from ._transform import scale, rotate, rotation_matrix, rotation_angles, rotation_matrix_from_axis_and_angle, rotation_matrix_from_directions + +# --- Geometry types --- from ._box import Box, BaseBox, Cuboid, bounding_box from ._sphere import Sphere from ._cylinder import Cylinder, cylinder from ._grid import UniformGrid, enclosing_grid from ._graph import Graph, graph from ._mesh import Mesh, mesh, load_su2, load_gmsh, load_stl, mesh_from_numpy, build_mesh -from ._transform import embed, infinite_cylinder from ._heightmap import Heightmap from ._sdf_grid import SDFGrid, sample_sdf from ._sdf import SDF, numpy_sdf +from ._embed import embed, infinite_cylinder + +# --- Top-level functions --- from ._geom_ops import union, intersection from ._convert import surface_mesh, as_sdf from ._geom_functions import line_trace diff --git a/phi/geom/_box.py b/phi/geom/_box.py index 84cc55f94..6f21cc6c5 100644 --- a/phi/geom/_box.py +++ b/phi/geom/_box.py @@ -7,6 +7,7 @@ from phi.math import DimFilter from phiml.math import rename_dims, vec, stack, expand, instance from phiml.math._shape import parse_dim_order, dual, non_channel, non_batch +from . import rotate, rotation_matrix from ._geom import Geometry, _keep_vector from ..math import wrap, INF, Shape, channel, Tensor from ..math.magic import slicing_dict @@ -74,7 +75,7 @@ def global_to_local(self, global_position: Tensor, scale=True, origin='lower') - assert origin in ['lower', 'center', 'upper'] origin_loc = getattr(self, origin) pos = global_position if math.always_close(origin_loc, 0) else global_position - origin_loc - pos = math.rotate_vector(pos, self.rotation_matrix, invert=True) + pos = rotate(pos, self.rotation_matrix, invert=True) if scale: pos /= (self.half_size if origin == 'center' else self.size) return pos @@ -83,7 +84,7 @@ def local_to_global(self, local_position, scale=True, origin='lower'): assert origin in ['lower', 'center', 'upper'] origin_loc = getattr(self, origin) pos = local_position * (self.half_size if origin == 'center' else self.size) if scale else local_position - return math.rotate_vector(pos, self.rotation_matrix) + origin_loc + return rotate(pos, self.rotation_matrix) + origin_loc def largest(self, dim: DimFilter) -> 'BaseBox': dim = self.shape.without('vector').only(dim) @@ -137,7 +138,7 @@ def push(self, positions: Tensor, outward: bool = True, shift_amount: float = 0) if instance(self): shift, loc_to_center, rotation_matrix = math.at_min((shift, loc_to_center, rotation_matrix), key=math.vec_length(shift), dim=instance) shift = math.where(abs(shift) > abs(loc_to_center), abs(loc_to_center), shift) # ensure inward shift ends at center - shift = math.rotate_vector(shift, rotation_matrix) + shift = rotate(shift, rotation_matrix) return positions + math.where(loc_to_center < 0, 1, -1) * shift def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: @@ -221,7 +222,7 @@ def face_centers(self) -> Tensor: @property def face_normals(self) -> Tensor: unit_vectors = math.to_float(math.range(self.shape['vector']) == math.range(dual(**self.shape['vector'].untyped_dict))) - vectors = math.rotate_vector(unit_vectors, self.rotation_matrix) + vectors = rotate(unit_vectors, self.rotation_matrix) return vectors * math.vec(dual('side'), lower=-1, upper=1) @property @@ -442,7 +443,7 @@ def __init__(self, if 'vector' not in center.shape or center.shape.get_item_names('vector') is None: center = math.expand(center, channel(self._half_size)) self._center = center - self._rotation_matrix = None if rotation is None else math.rotation_matrix(rotation) + self._rotation_matrix = None if rotation is None else rotation_matrix(rotation) self._size_variable = size_variable def __repr__(self): diff --git a/phi/geom/_cylinder.py b/phi/geom/_cylinder.py index f519b36bb..9cf71f9ba 100644 --- a/phi/geom/_cylinder.py +++ b/phi/geom/_cylinder.py @@ -3,11 +3,10 @@ from typing import Union, Dict, Tuple, Optional, Sequence from phiml import math -from phiml.math import Shape, dual, wrap, Tensor, expand, vec, where, ncat, clip, length, normalize, rotate_vector, minimum, vec_squared, rotation_matrix, channel, instance, stack, maximum, PI, linspace, sin, cos, \ - rotation_matrix_from_directions, sqrt, batch +from phiml.math import (Shape, dual, wrap, Tensor, expand, vec, where, ncat, clip, length, normalize, minimum, vec_squared, channel, instance, stack, maximum, PI, linspace, sin, cos, sqrt, batch) from phiml.math._magic_ops import all_attributes, getitem_dataclass -from phiml.math.magic import slicing_dict -from ._geom import Geometry, _keep_vector +from ._geom import Geometry +from ._transform import rotate, rotation_matrix, rotation_matrix_from_directions, rotation_angles from ._sphere import Sphere @@ -47,7 +46,7 @@ def volume(self) -> math.Tensor: @cached_property def up(self): - return math.rotate_vector(vec(**{d: 1 if d == self.axis else 0 for d in self._center.vector.item_names}), self.rotation) + return rotate(vec(**{d: 1 if d == self.axis else 0 for d in self._center.vector.item_names}), self.rotation) def with_radius(self, radius: Tensor) -> 'Cylinder': return Cylinder(self._center, wrap(radius), self.depth, self.rotation, self.axis, self.variable_attrs, self.value_attrs) @@ -56,14 +55,14 @@ def with_depth(self, depth: Tensor) -> 'Cylinder': return Cylinder(self._center, self.radius, wrap(depth), self.rotation, self.axis, self.variable_attrs, self.value_attrs) def lies_inside(self, location): - pos = rotate_vector(location - self._center, self.rotation, invert=True) + pos = rotate(location - self._center, self.rotation, invert=True) r = pos.vector[self.radial_axes] h = pos.vector[self.axis] inside = (vec_squared(r) <= self.radius**2) & (h >= -.5*self.depth) & (h <= .5*self.depth) return math.any(inside, instance(self)) # union for instance dimensions def approximate_signed_distance(self, location: Union[Tensor, tuple]): - location = math.rotate_vector(location - self._center, self.rotation, invert=True) + location = rotate(location - self._center, self.rotation, invert=True) r = location.vector[self.radial_axes] h = location.vector[self.axis] top_h = .5*self.depth @@ -83,7 +82,7 @@ def approximate_signed_distance(self, location: Union[Tensor, tuple]): return math.min(sgn_dist, instance(self)) def approximate_closest_surface(self, location: Tensor): - location = math.rotate_vector(location - self._center, self.rotation, invert=True) + location = rotate(location - self._center, self.rotation, invert=True) r = location.vector[self.radial_axes] h = location.vector[self.axis] top_h = .5*self.depth @@ -112,8 +111,8 @@ def approximate_closest_surface(self, location: Tensor): sgn_dist = minimum(d_flat, d_cyl) * where(inside, -1, 1) delta = surf_point - location normal = where(flat_closer, normal_flat, normal_cyl) - delta = rotate_vector(delta, self.rotation) - normal = rotate_vector(normal, self.rotation) + delta = rotate(delta, self.rotation) + normal = rotate(normal, self.rotation) idx = None if instance(self): sgn_dist, delta, normal, idx = math.min((sgn_dist, delta, normal, range), instance(self), key=sgn_dist) @@ -123,7 +122,7 @@ def sample_uniform(self, *shape: math.Shape): r = Sphere(self._center[self.radial_axes], self.radius).sample_uniform(*shape) h = math.random_uniform(*shape, -.5*self.depth, .5*self.depth) rh = ncat([r, h], self._center.shape['vector']) - return rotate_vector(rh, self.rotation) + return rotate(rh, self.rotation) def bounding_radius(self): return length(vec(rad=self.radius, dep=.5*self.depth), 'vector') @@ -157,8 +156,8 @@ def __stack__(values: tuple, dim: Shape, **kwargs) -> 'Geometry': if any(v.rotation is not None for v in values): matrices = [v.rotation for v in values] if any(m is None for m in matrices): - any_angle = math.rotation_angles([m for m in matrices if m is not None][0]) - unit_matrix = math.rotation_matrix(any_angle * 0) + any_angle = rotation_angles([m for m in matrices if m is not None][0]) + unit_matrix = rotation_matrix(any_angle * 0) matrices = [unit_matrix if m is None else m for m in matrices] rotation = stack(matrices, dim, **kwargs) else: @@ -215,7 +214,7 @@ def vertex_rings(self, count: Shape) -> Tensor: c = cos(angle) * self.radius r = stack([s, c], channel(vector=self.radial_axes)) x = ncat([h, r], self._center.shape['vector'], expand_values=True) - return math.rotate_vector(x, self.rotation) + self._center + return rotate(x, self.rotation) + self._center raise NotImplementedError diff --git a/phi/geom/_embed.py b/phi/geom/_embed.py new file mode 100644 index 000000000..675cb20dc --- /dev/null +++ b/phi/geom/_embed.py @@ -0,0 +1,158 @@ +from numbers import Number +from typing import Tuple, Union, Dict, Any + +from phiml.math import spatial, channel, stack, expand, INF + +from phi import math +from phi.math import Tensor, Shape +from phiml.math.magic import slicing_dict +from . import BaseBox, Box, Cuboid +from ._geom import Geometry +from ._sphere import Sphere +from phiml.math._shape import parse_dim_order + + +class _EmbeddedGeometry(Geometry): + + def __init__(self, geometry, axes: Tuple[str]): + self.geometry = geometry + self.axes = axes # spatial axis order + + @property + def spatial_rank(self) -> int: + return len(self.axes) + + @property + def center(self) -> Tensor: + g_cen = dict(**self.geometry.bounding_half_extent().vector) + return stack({dim: g_cen.get(dim, 0) for dim in self.vector.item_names}, channel('vector')) + + @property + def shape(self) -> Shape: + return self.geometry.shape.with_dim_size('vector', self.axes) + + @property + def volume(self) -> Tensor: + raise NotImplementedError() + + def unstack(self, dimension: str) -> tuple: + raise NotImplementedError() + + def _down_project(self, location: Tensor): + item_names = list(location.shape.get_item_names('vector')) + for dim in self.axes: + if dim not in self.geometry.shape.get_item_names('vector'): + item_names.remove(dim) + projected_loc = location.vector[item_names] + return projected_loc + + def __getitem__(self, item): + item = slicing_dict(self, item) + if 'vector' in item: + axes = channel(vector=self.axes).after_gather(item).item_names[0] + if all(a in self.geometry.vector.item_names for a in axes): + return self.geometry[item] + item['vector'] = [a for a in axes if a in self.geometry.vector.item_names] + else: + axes = self.axes + projected = self.geometry[item] + if projected.spatial_rank == 0: + return Box(**{a: None for a in axes}) + assert not isinstance(projected, BaseBox), f"_EmbeddedGeometry reduced to a Box but should already have been a box. Was {self.geometry}" + if isinstance(projected, Sphere) and projected.spatial_rank: # 1D spheres are just boxes + box1d = Cuboid(projected.center, expand(projected.radius, projected.center.shape['vector'])) + emb = _EmbeddedGeometry(box1d, axes) + return Cuboid(emb.center, emb.bounding_half_extent()) + return _EmbeddedGeometry(projected, axes) + + def lies_inside(self, location: Tensor) -> Tensor: + return self.geometry.lies_inside(self._down_project(location)) + + def approximate_signed_distance(self, location: Tensor) -> Tensor: + return self.geometry.approximate_signed_distance(self._down_project(location)) + + def sample_uniform(self, *shape: math.Shape) -> Tensor: + raise NotImplementedError() + + def bounding_radius(self) -> Tensor: + raise NotImplementedError() + + def bounding_half_extent(self) -> Tensor: + g_ext = dict(**self.geometry.bounding_half_extent().vector) + return stack({dim: g_ext.get(dim, INF) for dim in self.vector.item_names}, channel('vector')) + + def shifted(self, delta: Tensor) -> 'Geometry': + raise NotImplementedError() + + def at(self, center: Tensor) -> 'Geometry': + raise NotImplementedError() + + def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': + raise NotImplementedError() + + def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': + raise NotImplementedError() + + def __hash__(self): + return hash(self.geometry) + hash(self.axes) + + @property + def boundary_elements(self) -> Dict[Any, Dict[str, slice]]: + return self.geometry.boundary_elements + + @property + def boundary_faces(self) -> Dict[Any, Dict[str, slice]]: + return self.geometry.boundary_faces + + +def embed(geometry: Geometry, projected_dims: Union[math.Shape, str, tuple, list, None]) -> Geometry: + """ + Adds fake spatial dimensions to a geometry. + The geometry value will be constant along the added dimensions, as if it had infinite length in these directions. + + Dimensions that are already present with `geometry` are ignored. + + Args: + geometry: `Geometry` + projected_dims: Additional dimensions + + Returns: + `Geometry` with spatial rank `geometry.spatial_rank + projected_dims.rank`. + """ + if projected_dims is None: + return geometry + axes = parse_dim_order(projected_dims) + embedded_axes = [a for a in axes if a not in geometry.shape.get_item_names('vector')] + if not embedded_axes: + return geometry[axes] + # --- add dims from geometry to axes --- + for name in reversed(geometry.shape.get_item_names('vector')): + if name not in projected_dims: + axes = (name,) + axes + if isinstance(geometry, BaseBox): + box = geometry.corner_representation() + embedded = box * Box(**{dim: None for dim in embedded_axes}) + return embedded[axes] + return _EmbeddedGeometry(geometry, axes) + + +def infinite_cylinder(center=None, radius=None, inf_dim: Union[str, Shape, tuple, list] = None, **center_) -> Geometry: + """ + Creates an infinite cylinder. + This is equal to embedding an `n`-dimensional `Sphere` in `n+1` dimensions. + + See Also: + `Sphere`, `embed` + + Args: + center: Center coordinates without `inf_dim`. Alternatively use keyword arguments. + radius: Cylinder radius. + inf_dim: Dimension along which the cylinder is infinite. + Use `Geometry.rotated()` if the direction does not align with an axis. + **center_: Alternatively specify center coordinates without `inf_dim` as keyword arguments. + + Returns: + `Geometry` + """ + sphere = Sphere(center, radius, **center_) + return embed(sphere, inf_dim) diff --git a/phi/geom/_functions.py b/phi/geom/_functions.py index 2bbf68bca..f4265ae76 100644 --- a/phi/geom/_functions.py +++ b/phi/geom/_functions.py @@ -137,3 +137,5 @@ def distance_line_point(line_offset: Tensor, line_direction: Tensor, point: Tens if not is_direction_normalized: c /= vec_length(line_direction) return c + + diff --git a/phi/geom/_geom.py b/phi/geom/_geom.py index b26faa639..54811e077 100644 --- a/phi/geom/_geom.py +++ b/phi/geom/_geom.py @@ -1,11 +1,10 @@ import warnings from numbers import Number -from typing import Union, Dict, Any, Tuple, Callable +from typing import Union, Dict, Any, Tuple, Callable, TypeVar -from phi import math -from phi.math import Tensor, Shape, non_channel, wrap, shape, Extrapolation -from phi.math.magic import BoundDim, slicing_dict -from phiml.math import non_batch, tensor_like +from phiml import math +from phiml.math import Tensor, Shape, non_channel, wrap, shape, Extrapolation, non_batch, tensor_like +from phiml.math.magic import BoundDim, slicing_dict from phiml.math._magic_ops import variable_attributes, expand, find_differences @@ -747,6 +746,9 @@ def __getitem__(self, item): return Point(self._location[_keep_vector(slicing_dict(self, item))]) +GeometricType = TypeVar("GeometricType", Tensor, Geometry) + + class GeometryException(BaseException): """ Raised when an operation is fundamentally not possible for a `Geometry`. @@ -789,42 +791,6 @@ def _keep_vector(dim_selection: dict) -> dict: return item -def rotate(geometry: Geometry, rot: Union[float, Tensor], pivot: Tensor = None) -> Geometry: - """ - Rotate a `Geometry` about an axis given by `rot` and `pivot`. - - Args: - geometry: `Geometry` to rotate - rot: Rotation, either as Euler angles or rotation matrix. - pivot: Any point lying on the rotation axis. Defaults to the bounding box center. - - Returns: - Rotated `Geometry` - """ - if pivot is None: - pivot = geometry.bounding_box().center - center = pivot + math.rotate_vector(geometry.center - pivot, rot) - return geometry.rotated(rot).at(center) - - -def scale(geometry: Geometry, scale: float | Tensor, pivot: Tensor = None) -> Geometry: - """ - Scale a `Geometry` about a pivot point. - - Args: - geometry: `Geometry` to scale. - scale: Scaling factor. - pivot: Point that stays fixed under the scaling operation. Defaults to the bounding box center. - - Returns: - Rotated `Geometry` - """ - if pivot is None: - pivot = geometry.bounding_box().center - center = pivot + scale * (geometry.center - pivot) - return geometry.scaled(scale).at(center) - - def slice_off_constant_faces(obj, boundary_slices: Dict[Any, Dict[str, slice]], boundary: Extrapolation): """ Removes slices of `obj` where the boundary conditions fully determine the values. diff --git a/phi/geom/_geom_functions.py b/phi/geom/_geom_functions.py index 6b9519134..04e255438 100644 --- a/phi/geom/_geom_functions.py +++ b/phi/geom/_geom_functions.py @@ -2,11 +2,32 @@ from typing import Tuple, Optional from phiml import math -from phiml.math import Tensor, stack, instance, wrap +from phiml.math import Tensor, stack, instance, wrap, shape +from . import Cylinder from ._geom import Geometry +def length(obj: Geometry | Tensor, eps=1e-5) -> Tensor: + """ + Returns the length of a vector `Tensor` or geometric object with a length-like property. + + Args: + obj: + eps: Minimum valid vector length. Use to avoid `inf` gradients for zero-length vectors. + Lengths shorter than `eps` are set to 0. + + Returns: + Length as `Tensor` + """ + if isinstance(obj, Tensor): + assert 'vector' in obj.shape, f"length() requires 'vector' dim but got {type(obj)} with shape {shape(obj)}." + return math.length(obj, 'vector', eps) + elif isinstance(obj, Cylinder): + return obj.depth + raise ValueError(obj) + + def line_trace(geo: Geometry, origin: Tensor, direction: Tensor, side='both', tolerance=None, max_iter=64, step_size=.9, max_line_length=None) -> Tuple[Tensor, Tensor, Tensor, Tensor, Optional[Tensor]]: """ Trace a line until it hits the surface of `geo`. diff --git a/phi/geom/_geom_ops.py b/phi/geom/_geom_ops.py index e00fd71d4..0ff2f22c1 100644 --- a/phi/geom/_geom_ops.py +++ b/phi/geom/_geom_ops.py @@ -11,7 +11,8 @@ from phiml.math.magic import PhiTreeNode from ._box import bounding_box, Box -from ._geom import Geometry, NoGeometry, rotate +from ._geom import Geometry, NoGeometry +from ._transform import rotate from ._geom import InvertedGeometry from ..math import Tensor, instance from ..math.magic import slicing_dict diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index f1a233a66..93b890012 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -10,14 +10,15 @@ from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d, non_dual, nonzero, stored_indices, stored_values, scatter, \ find_closest, sqrt, where, vec_normalize, argmax, broadcast, to_int32, cross_product, zeros, random_normal, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ - assert_close, shift, pad, extrapolation, NUMPY, sum as sum_, with_diagonal, flatten, ones_like, dim_mask + assert_close, shift, pad, extrapolation, NUMPY, sum as sum_, with_diagonal, flatten, ones_like, dim_mask, math from phiml.math._magic_ops import getitem_dataclass from phiml.math._sparse import CompactSparseTensor from phiml.math.extrapolation import as_extrapolation, PERIODIC from phiml.math.magic import slicing_dict from . import bounding_box from ._functions import plane_sgn_dist -from ._geom import Geometry, Point, scale, NoGeometry +from ._geom import Geometry, Point, NoGeometry +from ._transform import scale from ._box import Box, BaseBox from ._graph import Graph, graph from ..math import Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, \ @@ -31,6 +32,8 @@ def __call__(cls, elements: Tensor, element_rank: int, boundaries: Dict[str, Dict[str, slice]], + periodic: Sequence[str], + face_format: str = 'csc', max_cell_walk: int = None, variables=('vertices',), values=()): @@ -42,8 +45,8 @@ def __call__(cls, vertices = Point(vertices) if max_cell_walk is None: max_cell_walk = 2 if instance(elements).volume > 1 else 1 - result = cls.__new__(cls, vertices, elements, element_rank, boundaries, max_cell_walk, variables, values) - result.__init__(vertices, elements, element_rank, boundaries, max_cell_walk, variables, values) # also calls __post_init__() + result = cls.__new__(cls, vertices, elements, element_rank, boundaries, periodic, face_format, max_cell_walk, variables, values) + result.__init__(vertices, elements, element_rank, boundaries, periodic, face_format, max_cell_walk, variables, values) # also calls __post_init__() return result @@ -319,7 +322,7 @@ def filter_unused_vertices(self) -> 'Mesh': else: filtered_coo = coo_matrix((coo.data, (coo.row, new_index)), shape=(instance(self.elements).volume, instance(vertices).volume)) # ToDo keep sparse format elements = wrap(filtered_coo, self.elements.shape.without_sizes()) - return Mesh(vertices, elements, self.element_rank, self.boundaries, self._center, self._volume, self._normals, self.face_centers, self.face_normals, self.face_areas, None, v_normals, vertex_connectivity, self._element_connectivity, self._max_cell_walk) + return Mesh(vertices, elements, self.element_rank, self.boundaries, self.center, self._volume, self.normals, self.face_centers, self.face_normals, self.face_areas, None, v_normals, vertex_connectivity, self._element_connectivity, self.max_cell_walk) @property def volume(self) -> Tensor: @@ -358,37 +361,35 @@ def vertex_positions(self) -> Tensor: return si2d(self.vertices.center) def lies_inside(self, location: Tensor) -> Tensor: - idx = find_closest(self._center, location) - for i in range(self._max_cell_walk): - idx, leaves_mesh, is_outside, *_ = self.cell_walk_towards(location, idx, allow_exit=i == self._max_cell_walk - 1) + idx = find_closest(self.center, location) + for i in range(self.max_cell_walk): + idx, leaves_mesh, is_outside, *_ = self.cell_walk_towards(location, idx, allow_exit=i == self.max_cell_walk - 1) return ~(leaves_mesh & is_outside) def approximate_signed_distance(self, location: Union[Tensor, tuple]) -> Tensor: if self.element_rank == 2 and self.spatial_rank == 3: - closest_elem = find_closest(self._center, location) - center = self._center[closest_elem] - normal = self._normals[closest_elem] + closest_elem = find_closest(self.center, location) + center = self.center[closest_elem] + normal = self.normals[closest_elem] return plane_sgn_dist(center, normal, location) - if self._center is None: - raise NotImplementedError("Mesh.approximate_signed_distance only available when faces are built.") - idx = find_closest(self._center, location) - for i in range(self._max_cell_walk): + idx = find_closest(self.center, location) + for i in range(self.max_cell_walk): idx, leaves_mesh, is_outside, distances, nb_idx = self.cell_walk_towards(location, idx, allow_exit=False) - return max(distances, dual) + return math.max(distances, dual) def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, Tensor, Tensor, Tensor]: if self.element_rank == 2 and self.spatial_rank == 3: - closest_elem = find_closest(self._center, location) - center = self._center[closest_elem] - normal = self._normals[closest_elem] + closest_elem = find_closest(self.center, location) + center = self.center[closest_elem] + normal = self.normals[closest_elem] face_size = sqrt(self._volume) * 4 size = face_size[closest_elem] sgn_dist = plane_sgn_dist(center, normal, location) delta = center - location # this is not accurate... outward = where(abs(sgn_dist) < size, normal, vec_normalize(delta)) return sgn_dist, delta, outward, None, closest_elem - # idx = find_closest(self._center, location) - # for i in range(self._max_cell_walk): + # idx = find_closest(self.center, location) + # for i in range(self.max_cell_walk): # idx, leaves_mesh, is_outside, distances, nb_idx = self.cell_walk_towards(location, idx, allow_exit=False) # sgn_dist = max(distances, dual) # cell_normals = self.face_normals[idx] @@ -414,7 +415,7 @@ def cell_walk_towards(self, location: Tensor, start_cell_idx: Tensor, allow_exit closest_face_centers = self.face_centers[start_cell_idx] offsets = closest_normals.vector @ closest_face_centers.vector # this dot product could be cashed in the mesh distances = closest_normals.vector @ location.vector - offsets - is_outside = any(distances > 0, dual) + is_outside = math.any(distances > 0, dual) nb_idx = argmax(distances, dual).index[0] # cell index or boundary face index leaves_mesh = nb_idx >= instance(self).volume next_idx = where(is_outside & (~leaves_mesh | allow_exit), nb_idx, start_cell_idx) @@ -427,13 +428,13 @@ def bounding_radius(self) -> Tensor: center = self.elements * self.center vert_pos = rename_dims(self.vertices.center, instance, dual) dist_to_vert = vec_length(vert_pos - center) - max_dist = max(dist_to_vert, dual) + max_dist = math.max(dist_to_vert, dual) return max_dist def bounding_half_extent(self) -> Tensor: center = self.elements * self.center vert_pos = rename_dims(self.vertices.center, instance, dual) - max_delta = max(abs(vert_pos - center), dual) + max_delta = math.max(abs(vert_pos - center), dual) return max_delta def bounding_box(self) -> 'BaseBox': @@ -441,7 +442,7 @@ def bounding_box(self) -> 'BaseBox': @property def bounds(self): - return Box(min(self.vertices.center, instance), max(self.vertices.center, instance)) + return Box(math.min(self.vertices.center, instance), math.max(self.vertices.center, instance)) def at(self, center: Tensor) -> 'Mesh': if instance(self.elements) in center.shape: @@ -466,8 +467,8 @@ def shifted(self, delta: Tensor) -> 'Mesh': else: # shift everything # ToDo transfer cached properties vertices = self.vertices.shifted(delta) - center = self._center + delta - return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, self._volume, self._normals, self.face_centers, self.face_normals, self.face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) + center = self.center + delta + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, self._volume, self.normals, self.face_centers, self.face_normals, self.face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self.max_cell_walk) def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': raise NotImplementedError @@ -475,10 +476,10 @@ def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': def scaled(self, factor: float | Tensor) -> 'Geometry': pivot = self.bounds.center vertices = scale(self.vertices, factor, pivot) - center = scale(Point(self._center), factor, pivot).center + center = scale(Point(self.center), factor, pivot).center volume = self._volume * factor**self.element_rank if self._volume is not None else None face_areas = None - return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, volume, self._normals, self.face_centers, self.face_normals, face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self._max_cell_walk) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, volume, self.normals, self.face_centers, self.face_normals, face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self.max_cell_walk) def __getitem__(self, item): item: dict = slicing_dict(self, item) @@ -657,7 +658,7 @@ def mesh(vertices: Geometry | Tensor, assert all(p in vertices.vector.item_names for p in periodic_dims), f"Periodic boundaries must be named after axes, e.g. {vertices.vector.item_names} but got {periodic}" for base in periodic_dims: assert base+'+' in boundaries and base+'-' in boundaries, f"Missing boundaries for periodicity '{base}'. Make sure '{base}+' and '{base}-' are keys in boundaries dict, got {tuple(boundaries)}" - return Mesh(vertices, elements, element_rank, boundaries, periodic_dims, face_format, max_cell_walk) + return Mesh(vertices, elements, element_rank, boundaries, periodic_dims, face_format=face_format, max_cell_walk=max_cell_walk) def build_faces_2d(vertices: Tensor, # (vertices:i, vector) @@ -717,12 +718,11 @@ def build_faces_2d(vertices: Tensor, # (vertices:i, vector) bnd_el_coo_v_idx = coo_matrix((bnd_coo_vert+1, (bnd_coo_idx, bnd_coo_vert)), shape=(end, instance(vertices).size)) ptr = np.cumsum(np.asarray(el_coo.sum(1))) first_ptr = np.pad(ptr, (1, 0))[:-1] - last_ptr = ptr - 1 alt1 = np.arange(el_coo.data.size) % 2 alt2 = (1 - alt1) alt2[first_ptr] = alt1[first_ptr] alt3 = (1 - alt1) - alt3[last_ptr] = alt1[last_ptr] + alt3[ptr - 1] = alt1[ptr - 1] v_indices = [] for alt in [alt1, (1-alt1), alt2, alt3]: el_coo.data = alt + 1e-10 @@ -799,21 +799,21 @@ def build_mesh(bounds: Box = None, vert_pos = meshgrid(resolution + 1) / resolution * bounds.size + bounds.lower # centroids = UniformGrid(resolution, bounds).center dx = bounds.size / resolution - regular_size = min(dx, channel) + regular_size = math.min(dx, channel) vert_pos, polygons, boundaries = build_quadrilaterals(vert_pos, resolution, obstacles, bounds, regular_size * max_squish) if max_squish is not None: lin_vert_pos = pack_dims(vert_pos, spatial, instance('polygon')) corner_pos = lin_vert_pos[polygons] - min_pos = min(corner_pos, '~polygon') - max_pos = max(corner_pos, '~polygon') - cell_sizes = min(max_pos - min_pos, 'vector') + min_pos = math.min(corner_pos, '~polygon') + max_pos = math.max(corner_pos, '~polygon') + cell_sizes = math.min(max_pos - min_pos, 'vector') too_small = cell_sizes < regular_size * max_squish # --- remove too small cells --- removed = polygons[too_small] removed_centers = mean(lin_vert_pos[removed], '~polygon') kept_vert = removed[{'~polygon': 0}] vert_pos = scatter(lin_vert_pos, kept_vert, removed_centers) - vertex_map = range(non_channel(lin_vert_pos)) + vertex_map = math.range(non_channel(lin_vert_pos)) vertex_map = scatter(vertex_map, rename_dims(removed, '~polygon', instance('poly_list')), expand(kept_vert, instance(poly_list=4))) polygons = polygons[~too_small] polygons = vertex_map[polygons] @@ -825,7 +825,7 @@ def build_single_mesh(vert_pos, polygons, boundaries): polygon_list = reshaped_numpy(polygons, [..., dual]) boundaries = {b: edges.numpy('edges,~vert') for b, edges in boundaries.items()} return mesh_from_numpy(points_np, polygon_list, boundaries, cell_dim=cell_dim, face_format=face_format) - return map(build_single_mesh, vert_pos, polygons, boundaries, dims=batch) + return math.map(build_single_mesh, vert_pos, polygons, boundaries, dims=batch) def build_quadrilaterals(vert_pos, resolution: Shape, obstacles: Dict[str, Geometry], bounds: Box, min_size) -> Tuple[Tensor, Tensor, dict]: @@ -904,7 +904,7 @@ def face_curvature(mesh: Mesh): curvature_tensor = .5 / mesh.volume * (e1 * dn1 + e2 * dn2 + e3 * dn3) scalar_curvature = sum_([curvature_tensor[{'vector': d, '~vector': d}] for d in mesh.vector.item_names], '0') return curvature_tensor, scalar_curvature - # vec_curvature = max(v_normals, dual) - min(v_normals, dual) # positive / negative + # vec_curvature = math.max(v_normals, dual) - math.min(v_normals, dual) # positive / negative def save_tri_mesh(file: str, mesh: Mesh, **extra_data): diff --git a/phi/geom/_transform.py b/phi/geom/_transform.py index 675cb20dc..c70b42a52 100644 --- a/phi/geom/_transform.py +++ b/phi/geom/_transform.py @@ -1,158 +1,188 @@ -from numbers import Number -from typing import Tuple, Union, Dict, Any - -from phiml.math import spatial, channel, stack, expand, INF - -from phi import math -from phi.math import Tensor, Shape -from phiml.math.magic import slicing_dict -from . import BaseBox, Box, Cuboid -from ._geom import Geometry -from ._sphere import Sphere -from phiml.math._shape import parse_dim_order - - -class _EmbeddedGeometry(Geometry): - - def __init__(self, geometry, axes: Tuple[str]): - self.geometry = geometry - self.axes = axes # spatial axis order - - @property - def spatial_rank(self) -> int: - return len(self.axes) - - @property - def center(self) -> Tensor: - g_cen = dict(**self.geometry.bounding_half_extent().vector) - return stack({dim: g_cen.get(dim, 0) for dim in self.vector.item_names}, channel('vector')) - - @property - def shape(self) -> Shape: - return self.geometry.shape.with_dim_size('vector', self.axes) - - @property - def volume(self) -> Tensor: - raise NotImplementedError() - - def unstack(self, dimension: str) -> tuple: - raise NotImplementedError() - - def _down_project(self, location: Tensor): - item_names = list(location.shape.get_item_names('vector')) - for dim in self.axes: - if dim not in self.geometry.shape.get_item_names('vector'): - item_names.remove(dim) - projected_loc = location.vector[item_names] - return projected_loc - - def __getitem__(self, item): - item = slicing_dict(self, item) - if 'vector' in item: - axes = channel(vector=self.axes).after_gather(item).item_names[0] - if all(a in self.geometry.vector.item_names for a in axes): - return self.geometry[item] - item['vector'] = [a for a in axes if a in self.geometry.vector.item_names] - else: - axes = self.axes - projected = self.geometry[item] - if projected.spatial_rank == 0: - return Box(**{a: None for a in axes}) - assert not isinstance(projected, BaseBox), f"_EmbeddedGeometry reduced to a Box but should already have been a box. Was {self.geometry}" - if isinstance(projected, Sphere) and projected.spatial_rank: # 1D spheres are just boxes - box1d = Cuboid(projected.center, expand(projected.radius, projected.center.shape['vector'])) - emb = _EmbeddedGeometry(box1d, axes) - return Cuboid(emb.center, emb.bounding_half_extent()) - return _EmbeddedGeometry(projected, axes) - - def lies_inside(self, location: Tensor) -> Tensor: - return self.geometry.lies_inside(self._down_project(location)) - - def approximate_signed_distance(self, location: Tensor) -> Tensor: - return self.geometry.approximate_signed_distance(self._down_project(location)) - - def sample_uniform(self, *shape: math.Shape) -> Tensor: - raise NotImplementedError() - - def bounding_radius(self) -> Tensor: - raise NotImplementedError() - - def bounding_half_extent(self) -> Tensor: - g_ext = dict(**self.geometry.bounding_half_extent().vector) - return stack({dim: g_ext.get(dim, INF) for dim in self.vector.item_names}, channel('vector')) - - def shifted(self, delta: Tensor) -> 'Geometry': - raise NotImplementedError() - - def at(self, center: Tensor) -> 'Geometry': - raise NotImplementedError() - - def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': - raise NotImplementedError() - - def scaled(self, factor: Union[float, Tensor]) -> 'Geometry': - raise NotImplementedError() - - def __hash__(self): - return hash(self.geometry) + hash(self.axes) - - @property - def boundary_elements(self) -> Dict[Any, Dict[str, slice]]: - return self.geometry.boundary_elements +from typing import Optional - @property - def boundary_faces(self) -> Dict[Any, Dict[str, slice]]: - return self.geometry.boundary_faces - - -def embed(geometry: Geometry, projected_dims: Union[math.Shape, str, tuple, list, None]) -> Geometry: +from phiml import math +from phiml.math import Tensor, channel, rename_dims, wrap, shape, normalize, cross_product, dual, stack, length + +from ._geom import Geometry, GeometricType + + +def scale(obj: GeometricType, scale: float | Tensor, pivot: Tensor = None, dim='vector') -> GeometricType: + """ + Scale a `Geometry` or vector `Tensor` about a pivot point. + + Args: + obj: `Geometry` to scale. + scale: Scaling factor. + pivot: Point that stays fixed under the scaling operation. Defaults to the bounding box center. + + Returns: + Rotated `Geometry` + """ + if scale is None: + return obj + if isinstance(obj, Geometry): + if pivot is None: + pivot = obj.bounding_box().center + center = pivot + scale * (obj.center - pivot) + return obj.scaled(scale).at(center) + elif isinstance(obj, Tensor): + assert 'vector' in obj.shape, f"vector must have exactly a channel dimension named 'vector'" + if pivot is None: + return obj * scale + raise NotImplementedError + raise ValueError(obj) + + +def rotate(obj: GeometricType, rot: float | Tensor | None, invert=False, pivot: Tensor | str = 'bounds') -> GeometricType: """ - Adds fake spatial dimensions to a geometry. - The geometry value will be constant along the added dimensions, as if it had infinite length in these directions. + Rotate a vector or `Geometry` about the `pivot`. + + Args: + obj: n-dimensional vector `Tensor` or `Geometry`. + rot: Euler angle(s) or rotation matrix. + `None` is interpreted as no rotation. + invert: Whether to apply the inverse rotation. + pivot: Either a point (`Tensor`) lying on the rotation axis or one of the following strings: 'bounds', 'individual'. + Vector tensors are rotated about the origin if `pivot` is not given as a `Tensor`. - Dimensions that are already present with `geometry` are ignored. + Returns: + Rotated vector as `Tensor` + """ + if rot is None: + return obj + if isinstance(obj, Geometry): + if pivot is None: + pivot = obj.bounding_box().center + center = pivot + rotate(obj.center - pivot, rot) + return obj.rotated(rot).at(center) + elif isinstance(obj, Tensor): + assert 'vector' in obj.shape, f"vector must have exactly a channel dimension named 'vector'" + matrix = rotation_matrix(rot) + if invert: + matrix = rename_dims(matrix, '~vector,vector', matrix.shape['vector'] + matrix.shape['~vector']) + assert matrix.vector.dual.size == obj.vector.size, f"Rotation matrix from {rot.shape} is {matrix.vector.dual.size}D but vector {obj.shape} is {obj.vector.size}D." + return math.dot(matrix, '~vector', obj, 'vector') + + +def rotation_matrix(x: float | math.Tensor | None, matrix_dim=channel('vector')) -> Optional[Tensor]: + """ + Create a 2D or 3D rotation matrix from the corresponding angle(s). Args: - geometry: `Geometry` - projected_dims: Additional dimensions + x: + 2D: scalar angle + 3D: Either vector pointing along the rotation axis with rotation angle as length or Euler angles. + Euler angles need to be laid out along a `angle` channel dimension with dimension names listing the spatial dimensions. + E.g. a 90° rotation about the z-axis is represented by `vec('angles', x=0, y=0, z=PI/2)`. + If a rotation matrix is passed for `angle`, it is returned without modification. + matrix_dim: Matrix dimension for 2D rotations. In 3D, the channel dimension of angle is used. Returns: - `Geometry` with spatial rank `geometry.spatial_rank + projected_dims.rank`. + Matrix containing `matrix_dim` in primal and dual form as well as all non-channel dimensions of `x`. """ - if projected_dims is None: - return geometry - axes = parse_dim_order(projected_dims) - embedded_axes = [a for a in axes if a not in geometry.shape.get_item_names('vector')] - if not embedded_axes: - return geometry[axes] - # --- add dims from geometry to axes --- - for name in reversed(geometry.shape.get_item_names('vector')): - if name not in projected_dims: - axes = (name,) + axes - if isinstance(geometry, BaseBox): - box = geometry.corner_representation() - embedded = box * Box(**{dim: None for dim in embedded_axes}) - return embedded[axes] - return _EmbeddedGeometry(geometry, axes) - - -def infinite_cylinder(center=None, radius=None, inf_dim: Union[str, Shape, tuple, list] = None, **center_) -> Geometry: + if x is None: + return None + if isinstance(x, Tensor) and '~vector' in x.shape and 'vector' in x.shape.channel and x.shape.get_size('~vector') == x.shape.get_size('vector'): + return x # already a rotation matrix + elif 'angle' in shape(x) and shape(x).get_size('angle') == 3: # 3D Euler angles + assert channel(x).rank == 1 and channel(x).size == 3, f"x for 3D rotations needs to be a 3-vector but got {x}" + s1, s2, s3 = math.sin(x).angle # x, y, z + c1, c2, c3 = math.cos(x).angle + matrix_dim = matrix_dim.with_size(shape(x).get_item_names('angle')) + return wrap([[c3 * c2, c3 * s2 * s1 - s3 * c1, c3 * s2 * c1 + s3 * s1], + [s3 * c2, s3 * s2 * s1 + c3 * c1, s3 * s2 * c1 - c3 * s1], + [-s2, c2 * s1, c2 * c1]], matrix_dim, matrix_dim.as_dual()) # Rz * Ry * Rx (1. rotate about X by first angle) + elif 'vector' in shape(x) and shape(x).get_size('vector') == 3: # 3D axis + x + angle = length(x) + s, c = math.sin(angle), math.cos(angle) + t = 1 - c + k1, k2, k3 = normalize(x, epsilon=1e-12).vector + matrix_dim = matrix_dim.with_size(shape(x).get_item_names('vector')) + return wrap([[c + k1**2 * t, k1 * k2 * t - k3 * s, k1 * k3 * t + k2 * s], + [k2 * k1 * t + k3 * s, c + k2**2 * t, k2 * k3 * t - k1 * s], + [k3 * k1 * t - k2 * s, k3 * k2 * t + k1 * s, c + k3**2 * t]], matrix_dim, matrix_dim.as_dual()) + else: # 2D rotation + sin = wrap(math.sin(x)) + cos = wrap(math.cos(x)) + return wrap([[cos, -sin], [sin, cos]], matrix_dim, matrix_dim.as_dual()) + + +def rotation_angles(rot: Tensor): """ - Creates an infinite cylinder. - This is equal to embedding an `n`-dimensional `Sphere` in `n+1` dimensions. + Compute the scalar x in 2D or the Euler angles in 3D from a given rotation matrix. + This function returns one valid solution but often, there are multiple solutions. - See Also: - `Sphere`, `embed` + Args: + rot: Rotation matrix as created by `phi.math.rotation_matrix()`. + Must have exactly one channel and one dual dimension with equally-ordered elements. + + Returns: + Scalar x in 2D, Euler angles + """ + assert channel(rot).rank == 1 and dual(rot).rank == 1, f"Rotation matrix must have one channel and one dual dimension but got {rot.shape}" + if channel(rot).size == 2: + cos = rot[{channel: 0, dual: 0}] + sin = rot[{channel: 1, dual: 0}] + return math.arctan(sin, divide_by=cos) + elif channel(rot).size == 3: + a2 = -math.arcsin(rot[{channel: 2, dual: 0}]) # ToDo handle [2, 0] == 1 (i.e. cos_theta == 0) + cos2 = math.cos(a2) + a1 = math.arctan(rot[{channel: 2, dual: 1}] / cos2, divide_by=rot[{channel: 2, dual: 2}] / cos2) + a3 = math.arctan(rot[{channel: 1, dual: 0}] / cos2, divide_by=rot[{channel: 0, dual: 0}] / cos2) + regular_sol = stack([a1, a2, a3], channel(angle=channel(rot).item_names[0])) + # --- pole case cos(theta) == 1 --- + a3_pole = 0 # unconstrained + bottom_pole = rot[{channel: 2, dual: 0}] < 0 + a2_pole = math.where(bottom_pole, 1.57079632679, -1.57079632679) + a1_pole = math.where(bottom_pole, math.arctan(rot[{channel: 0, dual: 1}], divide_by=rot[{channel: 0, dual: 2}]), math.arctan(-rot[{channel: 0, dual: 1}], divide_by=-rot[{channel: 0, dual: 2}])) + pole_sol = stack([a1_pole, a2_pole, a3_pole], channel(regular_sol)) + return math.where(abs(rot[{channel: 2, dual: 0}]) >= 1, pole_sol, regular_sol) + else: + raise ValueError(f"") + + +def rotation_matrix_from_directions(source_dir: Tensor, target_dir: Tensor, vec_dim: str = 'vector', epsilon=None) -> Tensor: + """ + Computes a rotation matrix A, such that `target_dir = A @ source_dir` + + Args: + source_dir: Two or three-dimensional vector. `Tensor` with channel dim called 'vector'. + target_dir: Two or three-dimensional vector. `Tensor` with channel dim called 'vector'. + + Returns: + Rotation matrix as `Tensor` with 'vector' dim and its dual counterpart. + """ + if source_dir.vector.size == 3: + source_dir = normalize(source_dir, vec_dim, epsilon=epsilon) + target_dir = normalize(target_dir, vec_dim, epsilon=epsilon) + axis = cross_product(source_dir, target_dir) + lim = 1-epsilon if epsilon is not None else 1 + angle = math.arccos(math.clip(source_dir.vector @ target_dir.vector, -lim, lim)) + return rotation_matrix_from_axis_and_angle(axis, angle, is_axis_normalized=False, epsilon=epsilon) + raise NotImplementedError + + +def rotation_matrix_from_axis_and_angle(axis: Tensor, angle: float | Tensor, vec_dim='vector', is_axis_normalized=False, epsilon=1e-5) -> Tensor: + """ + Computes a rotation matrix that rotates by `angle` around `axis`. Args: - center: Center coordinates without `inf_dim`. Alternatively use keyword arguments. - radius: Cylinder radius. - inf_dim: Dimension along which the cylinder is infinite. - Use `Geometry.rotated()` if the direction does not align with an axis. - **center_: Alternatively specify center coordinates without `inf_dim` as keyword arguments. + axis: 3D vector. `Tensor` with channel dim called 'vector'. + angle: Rotation angle. + is_axis_normalized: Whether `axis` has length 1. + epsilon: Minimum axis length. For shorter axes, the unit matrix is returned. Returns: - `Geometry` + Rotation matrix as `Tensor` with 'vector' dim and its dual counterpart. """ - sphere = Sphere(center, radius, **center_) - return embed(sphere, inf_dim) + if axis.vector.size == 3: # Rodrigues' rotation formula + axis = normalize(axis, vec_dim, epsilon=epsilon, allow_zero=False) if not is_axis_normalized else axis + kx, ky, kz = axis.vector + s = math.sin(angle) + c = 1 - math.cos(angle) + return wrap([ + (1 - c*(ky*ky+kz*kz), -kz*s + c*(kx*ky), ky*s + c*(kx*kz)), + ( kz*s + c*(kx*ky), 1 - c*(kx*kx+kz*kz), -kx*s + c*(ky * kz)), + ( -ky*s + c*(kx*kz), kx*s + c*(ky * kz), 1 - c*(kx*kx+ky*ky)), + ], axis.shape['vector'], axis.shape['vector'].as_dual()) + raise NotImplementedError \ No newline at end of file diff --git a/phi/vis/_matplotlib/_matplotlib_plots.py b/phi/vis/_matplotlib/_matplotlib_plots.py index 9bd5a80b4..4be31fc28 100644 --- a/phi/vis/_matplotlib/_matplotlib_plots.py +++ b/phi/vis/_matplotlib/_matplotlib_plots.py @@ -16,10 +16,10 @@ from phi import math from phi.field import StaggeredGrid, Field, CenteredGrid -from phi.geom import Sphere, BaseBox, Point, Box, Mesh, Graph, SDFGrid, SDF, UniformGrid +from phi.geom import Sphere, BaseBox, Point, Box, Mesh, Graph, SDFGrid, SDF, UniformGrid, rotate from phi.geom._heightmap import Heightmap from phi.geom._geom_ops import GeometryStack -from phi.geom._transform import _EmbeddedGeometry +from phi.geom._embed import _EmbeddedGeometry from phi.math import Tensor, channel, spatial, instance, non_channel, Shape, reshaped_numpy, shape from phi.vis._vis_base import display_name, PlottingLibrary, Recipe, index_label, only_stored_elements, to_field from phiml.math import wrap @@ -662,7 +662,7 @@ def _plot_points(axis: Axes, data: Field, dims: tuple, vector: Shape, color: Ten lower_y = y - h2 else: angles = reshaped_numpy(math.rotation_angles(data.geometry.rotation_matrix), [data.shape.non_channel]) - lower_x, lower_y = reshaped_numpy(data.geometry.center - math.rotate_vector(data.geometry.half_size, data.geometry.rotation_matrix), ['vector', data.shape.non_channel]) + lower_x, lower_y = reshaped_numpy(data.geometry.center - rotate(data.geometry.half_size, data.geometry.rotation_matrix), ['vector', data.shape.non_channel]) shapes = [plt.Rectangle((lxi, lyi), w2i * 2, h2i * 2, angle=ang*180/np.pi, linewidth=1, edgecolor='white', alpha=a, facecolor=ci) for lxi, lyi, w2i, h2i, ang, ci, a in zip(lower_x, lower_y, w2, h2, angles, mpl_colors, alphas)] axis.add_collection(matplotlib.collections.PatchCollection(shapes, match_original=True)) elif isinstance(data.geometry, Mesh): diff --git a/tests/commit/geom/test__transform.py b/tests/commit/geom/test__transform.py new file mode 100644 index 000000000..161ad0d1b --- /dev/null +++ b/tests/commit/geom/test__transform.py @@ -0,0 +1,43 @@ +from unittest import TestCase + +from phi import math +from phi.geom import rotate, rotation_matrix, rotation_angles +from phiml.math import wrap, batch + + +class TestGeom(TestCase): + + def test_rotate_vector(self): + # --- 2D --- + vec = rotate(math.vec(x=2, y=0), math.PI / 2) + math.assert_close(math.vec(x=0, y=2), vec, abs_tolerance=1e-5) + math.assert_close(math.vec(x=2, y=0), rotate(vec, math.PI / 2, invert=True), abs_tolerance=1e-5) + # --- 3D --- + vec = rotate(math.vec(x=2, y=0, z=0), rot=math.vec(x=0, y=math.PI / 2, z=0)) + math.assert_close(math.vec(x=0, y=0, z=-2), vec, abs_tolerance=1e-5) + math.assert_close(math.vec(x=2, y=0, z=0), rotate(vec, rot=math.vec(x=0, y=math.PI / 2, z=0), invert=True), abs_tolerance=1e-5) + # --- None --- + math.assert_close(math.vec(x=2, y=0), rotate(math.vec(x=2, y=0), None, invert=True)) + + def test_rotation_matrix(self): + def assert_matrices_equal(angle): + matrix = rotation_matrix(angle) + angle_ = rotation_angles(matrix) + math.assert_close(matrix, rotation_matrix(angle_), abs_tolerance=1e-5) + + angle = wrap([0, -math.PI/2, math.PI/2, -math.PI, math.PI, 2*math.PI], batch('angles')) + assert_matrices_equal(angle) + # --- 3D axis-angle --- + angle = wrap([0, -math.PI/2, math.PI/2, -math.PI, math.PI, 2*math.PI], batch('angles')) + assert_matrices_equal(math.vec(x=0, y=0, z=angle)) + assert_matrices_equal(math.vec(x=0, y=angle, z=0)) + assert_matrices_equal(math.vec(x=angle, y=0, z=0)) + assert_matrices_equal(math.vec(x=angle, y=angle, z=0)) + assert_matrices_equal(math.vec(x=angle, y=angle, z=angle)) + # --- 3D Euler angle --- + angle = wrap([0, -math.PI/2, math.PI/2, -math.PI, math.PI, 2*math.PI], batch('angles')) + assert_matrices_equal(math.vec('angle', x=0, y=0, z=angle)) + assert_matrices_equal(math.vec('angle', x=0, y=angle, z=0)) + assert_matrices_equal(math.vec('angle', x=angle, y=0, z=0)) + assert_matrices_equal(math.vec('angle', x=angle, y=angle, z=0)) + assert_matrices_equal(math.vec('angle', x=angle, y=angle, z=angle)) \ No newline at end of file From b6e3cda6c5b202b2ff5bd7a690297f2da78371a9 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 25 Nov 2024 15:11:22 +0100 Subject: [PATCH 58/71] [geom] Fix Mesh.vertex_connectivity, simplify --- phi/geom/_convert.py | 10 +-- phi/geom/_mesh.py | 121 ++++++++++++---------------- tests/commit/geom/test__sdf_grid.py | 2 +- 3 files changed, 56 insertions(+), 77 deletions(-) diff --git a/phi/geom/_convert.py b/phi/geom/_convert.py index 198d0a697..9b3ab9e64 100644 --- a/phi/geom/_convert.py +++ b/phi/geom/_convert.py @@ -83,9 +83,7 @@ def sdf_and_grad(x: Tensor): def surface_mesh(geo: Geometry, rel_dx: float = None, abs_dx: float = None, - method='auto', - build_vertex_connectivity=False, - build_normals=False) -> Mesh: + method='auto') -> Mesh: """ Create a surface `Mesh` from a Geometry. @@ -101,7 +99,7 @@ def surface_mesh(geo: Geometry, if geo.spatial_rank != 3: raise NotImplementedError("Only 3D SDF currently supported") if isinstance(geo, NoGeometry): - return mesh_from_numpy([], [], build_faces=False, element_rank=2, build_normals=False) + return mesh_from_numpy([], [], element_rank=2) if method == 'auto' and isinstance(geo, BaseBox): assert rel_dx is None and abs_dx is None, f"When method='auto', boxes will always use their corners as vertices. Leave rel_dx,abs_dx unspecified or pass 'lewiner' or 'lorensen' as method" vertices = pack_dims(geo.corners, dual, instance('vertices')) @@ -113,7 +111,7 @@ def surface_mesh(geo: Geometry, instance_offset = math.range_tensor(instance(geo)) * corner_count faces = wrap([v1, v2, v3], spatial('vertices'), instance('faces')) + instance_offset faces = pack_dims(faces, instance, instance('faces')) - return mesh(vertices, faces, element_rank=2, build_faces=False, build_vertex_connectivity=build_vertex_connectivity, build_normals=build_normals) + return mesh(vertices, faces, element_rank=2) elif method == 'auto' and isinstance(geo, Sphere): pass # ToDo analytic solution if isinstance(geo, SDFGrid): @@ -139,5 +137,5 @@ def generate_mesh(sdf_grid: SDFGrid) -> Mesh: vertices, faces, v_normals, _ = marching_cubes(sdf_numpy, level=0.0, spacing=dx, allow_degenerate=False, method=method) vertices += sdf_grid.bounds.lower.numpy() + .5 * dx with math.NUMPY: - return mesh_from_numpy(vertices, faces, element_rank=2, build_faces=False, build_vertex_connectivity=build_vertex_connectivity, build_normals=build_normals, cell_dim=instance('faces')) + return mesh_from_numpy(vertices, faces, element_rank=2, cell_dim=instance('faces')) return math.map(generate_mesh, sdf_grid, dims=batch) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index 93b890012..21c46d086 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -1,5 +1,4 @@ import os -import warnings from dataclasses import dataclass from functools import cached_property from numbers import Number @@ -8,50 +7,26 @@ import numpy as np from scipy.sparse import csr_matrix, coo_matrix +from phiml import math from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d, non_dual, nonzero, stored_indices, stored_values, scatter, \ - find_closest, sqrt, where, vec_normalize, argmax, broadcast, to_int32, cross_product, zeros, random_normal, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ - assert_close, shift, pad, extrapolation, NUMPY, sum as sum_, with_diagonal, flatten, ones_like, dim_mask, math + find_closest, sqrt, where, vec_normalize, argmax, broadcast, cross_product, zeros, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ + assert_close, shift, pad, extrapolation, sum as sum_, flatten, dim_mask, math, cumulative_sum, arange from phiml.math._magic_ops import getitem_dataclass from phiml.math._sparse import CompactSparseTensor from phiml.math.extrapolation import as_extrapolation, PERIODIC from phiml.math.magic import slicing_dict from . import bounding_box +from ._box import Box, BaseBox from ._functions import plane_sgn_dist from ._geom import Geometry, Point, NoGeometry -from ._transform import scale -from ._box import Box, BaseBox from ._graph import Graph, graph +from ._transform import scale from ..math import Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, \ pairwise_distances, concat, Extrapolation -class _MeshType(type): - """Metaclass containing the user-friendly (legacy) Mesh constructor.""" - def __call__(cls, - vertices: Union[Geometry, Tensor], - elements: Tensor, - element_rank: int, - boundaries: Dict[str, Dict[str, slice]], - periodic: Sequence[str], - face_format: str = 'csc', - max_cell_walk: int = None, - variables=('vertices',), - values=()): - if spatial(elements): - assert elements.dtype.kind == int, f"elements listing vertices must be integer lists but got dtype {elements.dtype}" - else: - assert elements.dtype.kind == bool, f"element matrices must be of type bool but got {elements.dtype}" - if not isinstance(vertices, Geometry): - vertices = Point(vertices) - if max_cell_walk is None: - max_cell_walk = 2 if instance(elements).volume > 1 else 1 - result = cls.__new__(cls, vertices, elements, element_rank, boundaries, periodic, face_format, max_cell_walk, variables, values) - result.__init__(vertices, elements, element_rank, boundaries, periodic, face_format, max_cell_walk, variables, values) # also calls __post_init__() - return result - - @dataclass(frozen=True) -class Mesh(Geometry, metaclass=_MeshType): +class Mesh(Geometry): """ Unstructured mesh, consisting of vertices and elements. @@ -73,9 +48,16 @@ class Mesh(Geometry, metaclass=_MeshType): face_format: str = 'csc' """Sparse matrix format for storing quantities that depend on a pair of neighboring elements, e.g. `face_area`, `face_normal`, `face_center`.""" max_cell_walk: int = None + """ Maximum number of steps to walk along the element connectivity in order to find a cell, e.g. for sampling at an arbitrary point.""" + + variable_attrs: Tuple[str, ...] = ('vertices',) # PhiML keyword + value_attrs: Tuple[str, ...] = () # PhiML keyword - variable_attrs: Tuple[str, ...] = ('vertices',) - value_attrs: Tuple[str, ...] = () + def __post_init__(self): + if spatial(self.elements): + assert self.elements.dtype.kind == int, f"elements listing vertices must be integer lists but got dtype {self.elements.dtype}" + else: + assert self.elements.dtype.kind == bool, f"element matrices must be of type bool but got {self.elements.dtype}" @cached_property def shape(self) -> Shape: @@ -117,15 +99,14 @@ def face_normals(self) -> Tensor: raise NotImplementedError @cached_property - def _faces(self) -> Dict[str, Tensor]: + def _faces(self) -> Dict[str, Any]: if self.element_rank == 2: - centers, normals, areas, boundary_slices, vertex_connectivity = build_faces_2d(self.vertices.center, self.elements, self.boundaries, self.periodic, self._vertex_mean, self.face_format) + centers, normals, areas, boundary_slices = build_faces_2d(self.vertices.center, self.elements, self.boundaries, self.periodic, self._vertex_mean, self.face_format) return { 'center': centers, 'normal': normals, 'area': areas, 'boundary_slices': boundary_slices, - 'vertex_connectivity': vertex_connectivity, } return None @@ -283,46 +264,45 @@ def element_connectivity(self) -> Tensor: def vertex_connectivity(self) -> Tensor: if isinstance(self.vertices, Graph): return self.vertices.connectivity - if self.element_rank == self.spatial_rank: - return self._faces['vertex_connectivity'] elif self.element_rank <= 2: - coo = to_format(self.elements, 'coo').numpy() - connected_points = coo.T @ coo # ToDo this also counts vertices not connected by a single line/face as long as they are part of the same element - if not np.all(connected_points.sum_(axis=1) > 0): - warnings.warn("some vertices have no element connection at all", RuntimeWarning) - connected_points.data = np.ones_like(connected_points.data) - vertex_connectivity = wrap(connected_points, instance(self.vertices), dual(self.elements)) - return vertex_connectivity + def single_vertex_connectivity(elements: Tensor): + indices = stored_indices(elements).index[dual(elements).name] + idx1 = indices.numpy() + v_count = sum_(elements, dual).numpy() + ptr_end = np.cumsum(v_count) + roll = np.arange(idx1.size) + 1 + roll[ptr_end-1] = ptr_end - v_count + idx2 = idx1[roll] + v_conn = coo_matrix((np.ones(idx1.size, dtype=bool), (idx1, idx2)), shape=(dual(elements).size,)*2).tocsr() + return wrap(v_conn, dual(elements).as_instance(), dual(elements)) + return math.map(single_vertex_connectivity, self.elements, dims=batch) raise NotImplementedError - @property + @cached_property def vertex_graph(self) -> Graph: - if isinstance(self.vertices, Graph): - return self.vertices - assert self._vertex_connectivity is not None, f"vertex_graph not available because vertex_connectivity has not been computed" - return graph(self.vertices, self._vertex_connectivity) + return self.vertices if isinstance(self.vertices, Graph) else graph(self.vertices, self.vertex_connectivity) def filter_unused_vertices(self) -> 'Mesh': coo = to_format(self.elements, 'coo').numpy() - has_element = np.asarray(coo.sum_(0) > 0)[0] - new_index = np.cumsum_(has_element) - 1 + has_element = np.asarray(coo.sum(0) > 0)[0] + new_index = np.cumsum(has_element) - 1 new_index_t = wrap(new_index, dual(self.elements)) has_element = wrap(has_element, instance(self.vertices)) has_element_d = si2d(has_element) vertices = self.vertices[has_element] - v_normals = self._vertex_normals[has_element_d] + v_normals = self.vertex_normals[has_element_d] vertex_connectivity = None - if self._vertex_connectivity is not None: - vertex_connectivity = stored_indices(self._vertex_connectivity).index.as_batch() - vertex_connectivity = new_index_t[{dual: vertex_connectivity}].index.as_channel() - vertex_connectivity = sparse_tensor(vertex_connectivity, stored_values(self._vertex_connectivity), non_batch(self._vertex_connectivity).with_sizes(instance(vertices).size), False) + # if self._vertex_connectivity is not None: + # vertex_connectivity = stored_indices(self._vertex_connectivity).index.as_batch() + # vertex_connectivity = new_index_t[{dual: vertex_connectivity}].index.as_channel() + # vertex_connectivity = sparse_tensor(vertex_connectivity, stored_values(self._vertex_connectivity), non_batch(self._vertex_connectivity).with_sizes(instance(vertices).size), False) if isinstance(self.elements, CompactSparseTensor): indices = new_index_t[{dual: self.elements._indices}] elements = CompactSparseTensor(indices, self.elements._values, self.elements._compressed_dims.with_size(instance(vertices).volume), self.elements._indices_constant, self.elements._matrix_rank) else: filtered_coo = coo_matrix((coo.data, (coo.row, new_index)), shape=(instance(self.elements).volume, instance(vertices).volume)) # ToDo keep sparse format elements = wrap(filtered_coo, self.elements.shape.without_sizes()) - return Mesh(vertices, elements, self.element_rank, self.boundaries, self.center, self._volume, self.normals, self.face_centers, self.face_normals, self.face_areas, None, v_normals, vertex_connectivity, self._element_connectivity, self.max_cell_walk) + return Mesh(vertices, elements, self.element_rank, self.boundaries, self.periodic, self.face_format, self.max_cell_walk, self.variable_attrs, self.value_attrs) @property def volume(self) -> Tensor: @@ -343,8 +323,9 @@ def volume(self) -> Tensor: @property def normals(self) -> Tensor: """Extrinsic element normal space. This is a 0D vector for solid elements and 1D for surface elements.""" - if isinstance(self.elements, CompactSparseTensor) and self.element_rank == 2: - corners = self.vertices[self.elements._indices] + if self.element_rank == 2: + three_vertices = nonzero(self.elements, 3, list_dims=dual) + corners = self.vertices.center[{instance: three_vertices}] assert dual(corners).size == 3, f"signed distance currently only supports triangles" v1, v2, v3 = unstack(corners, dual) return vec_normalize(cross_product(v2 - v1, v3 - v1)) @@ -382,7 +363,7 @@ def approximate_closest_surface(self, location: Tensor) -> Tuple[Tensor, Tensor, closest_elem = find_closest(self.center, location) center = self.center[closest_elem] normal = self.normals[closest_elem] - face_size = sqrt(self._volume) * 4 + face_size = sqrt(self.volume) * 4 size = face_size[closest_elem] sgn_dist = plane_sgn_dist(center, normal, location) delta = center - location # this is not accurate... @@ -451,10 +432,9 @@ def at(self, center: Tensor) -> 'Mesh': center = rename_dims(center, dual, instance(self.vertices)) if instance(self.vertices) in center.shape: vertices = self.vertices.at(center) - return mesh(vertices, self.elements, self.boundaries) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, self.periodic, self.face_format, self.max_cell_walk, self.variable_attrs, self.value_attrs) else: - shift = center - self.bounds.center - return self.shifted(shift) + return self.shifted(center - self.bounds.center) def shifted(self, delta: Tensor) -> 'Mesh': if instance(self.elements) in delta.shape: @@ -463,12 +443,12 @@ def shifted(self, delta: Tensor) -> 'Mesh': delta = rename_dims(delta, dual, instance(self.vertices)) if instance(self.vertices) in delta.shape: vertices = self.vertices.shifted(delta) - return mesh(vertices, self.elements, self.boundaries) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, self.periodic, self.face_format, self.max_cell_walk, self.variable_attrs, self.value_attrs) else: # shift everything # ToDo transfer cached properties + # copy: center+delta, normals, volume, face_centers+delta, face_areas, face_normals, vertex_normals, vertex_connectivity, element_connectivity vertices = self.vertices.shifted(delta) - center = self.center + delta - return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, self._volume, self.normals, self.face_centers, self.face_normals, self.face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self.max_cell_walk) + return Mesh(vertices, self.elements, self.element_rank, self.boundaries, self.periodic, self.face_format, self.max_cell_walk, self.variable_attrs, self.value_attrs) def rotated(self, angle: Union[float, Tensor]) -> 'Geometry': raise NotImplementedError @@ -477,7 +457,7 @@ def scaled(self, factor: float | Tensor) -> 'Geometry': pivot = self.bounds.center vertices = scale(self.vertices, factor, pivot) center = scale(Point(self.center), factor, pivot).center - volume = self._volume * factor**self.element_rank if self._volume is not None else None + volume = self.volume * factor**self.element_rank if self.volume is not None else None face_areas = None return Mesh(vertices, self.elements, self.element_rank, self.boundaries, center, volume, self.normals, self.face_centers, self.face_normals, face_areas, self.face_vertices, self._vertex_normals, self._vertex_connectivity, self._element_connectivity, self.max_cell_walk) @@ -651,6 +631,8 @@ def mesh(vertices: Geometry | Tensor, element_rank = 2 if min_vertices <= 4 else 3 # assume tri or quad mesh else: raise ValueError(vertices.vector.size) + if max_cell_walk is None: + max_cell_walk = 2 if instance(elements).volume > 1 else 1 # --- build faces --- periodic_dims = [] if periodic is not None: @@ -751,8 +733,7 @@ def build_faces_2d(vertices: Tensor, # (vertices:i, vector) edge_len = sparse_tensor(indices, edge_len, element_connectivity.shape, format='coo' if face_format == 'dense' else face_format, indices_constant=True) normal = tensor_like(edge_len, normal, value_order='original') edge_center = tensor_like(edge_len, edge_center, value_order='original') - vertex_connectivity = None - return edge_center, normal, edge_len, boundary_slices, vertex_connectivity + return edge_center, normal, edge_len, boundary_slices def build_mesh(bounds: Box = None, diff --git a/tests/commit/geom/test__sdf_grid.py b/tests/commit/geom/test__sdf_grid.py index f8d33a3d6..f3beea95f 100644 --- a/tests/commit/geom/test__sdf_grid.py +++ b/tests/commit/geom/test__sdf_grid.py @@ -38,7 +38,7 @@ def test_signed_distance(self): def test_closest_surface(self): sphere = Sphere(x=1, y=1, radius=.8) - sdf = sample_sdf(sphere, Box(x=(2, 3), y=3), x=100, y=100) + sdf = sample_sdf(sphere, Box(x=(2, 3), y=3), x=100, y=100, cache_surface=True) sgn_dist_sph, delta_sph, normal_sph, offset_sph, _ = sphere.approximate_closest_surface(sdf.points) sgn_dist_sdf, delta_sdf, normal_sdf, offset_sdf, _ = sdf.approximate_closest_surface(sdf.points) math.assert_close(sgn_dist_sph, sgn_dist_sdf, abs_tolerance=.1) From b49a4e7b27b917930125912543820c104ca03c0c Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 25 Nov 2024 21:38:27 +0100 Subject: [PATCH 59/71] =?UTF-8?q?[=CE=A6]=20Default=20to=20geom.length,=20?= =?UTF-8?q?geom.normalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phi/flow.py | 6 +++--- phi/geom/__init__.py | 2 +- phi/geom/_geom_functions.py | 24 +++++++++++++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/phi/flow.py b/phi/flow.py index eedd2d388..818829bba 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -40,12 +40,12 @@ dsum, isum, ssum, csum, mean, dmean, imean, smean, cmean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, scatter, gather, where, nonzero, - cross_product as cross, dot, convolve, vec_normalize as normalize, length, maximum, minimum, clip, # vector math - safe_div, length, is_finite, is_nan, is_inf, # Basic functions + cross_product as cross, dot, convolve, maximum, minimum, clip, # vector math + safe_div, is_finite, is_nan, is_inf, # Basic functions jit_compile, jit_compile_linear, minimize, gradient as functional_gradient, gradient, solve_linear, solve_nonlinear, iterate, identity, # jacobian, hessian, custom_gradient # Functional magic assert_close, always_close, equal, close ) -from .geom import union, rotate, scale +from .geom import union, rotate, scale, length, normalize from .vis import show, control, plot # Exceptions diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index 574ccafe0..0598c5960 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -31,6 +31,6 @@ # --- Top-level functions --- from ._geom_ops import union, intersection from ._convert import surface_mesh, as_sdf -from ._geom_functions import line_trace +from ._geom_functions import line_trace, length, normalize __all__ = [key for key in globals().keys() if not key.startswith('_')] diff --git a/phi/geom/_geom_functions.py b/phi/geom/_geom_functions.py index 04e255438..1dc272088 100644 --- a/phi/geom/_geom_functions.py +++ b/phi/geom/_geom_functions.py @@ -8,13 +8,13 @@ from ._geom import Geometry -def length(obj: Geometry | Tensor, eps=1e-5) -> Tensor: +def length(obj: Geometry | Tensor, epsilon=1e-5) -> Tensor: """ Returns the length of a vector `Tensor` or geometric object with a length-like property. Args: obj: - eps: Minimum valid vector length. Use to avoid `inf` gradients for zero-length vectors. + epsilon: Minimum valid vector length. Use to avoid `inf` gradients for zero-length vectors. Lengths shorter than `eps` are set to 0. Returns: @@ -22,12 +22,30 @@ def length(obj: Geometry | Tensor, eps=1e-5) -> Tensor: """ if isinstance(obj, Tensor): assert 'vector' in obj.shape, f"length() requires 'vector' dim but got {type(obj)} with shape {shape(obj)}." - return math.length(obj, 'vector', eps) + return math.length(obj, 'vector', epsilon) elif isinstance(obj, Cylinder): return obj.depth raise ValueError(obj) +def normalize(obj: Tensor, epsilon=1e-5, allow_infinite=False, allow_zero=True): + """ + Normalize a vector `Tensor` along the 'vector' dim. + + Args: + obj: `Tensor` with 'vector' dim. + epsilon: (Optional) Zero-length threshold. Vectors shorter than this length yield the unit vector (1, 0, 0, ...). + If not specified, the zero-vector yields `NaN` as it cannot be normalized. + allow_infinite: Allow infinite components in vectors. These vectors will then only points towards the infinite components. + allow_zero: Whether to return zero vectors for inputs smaller `epsilon` instead of a unit vector. + + Returns: + `Tensor` of the same shape as `obj`. + """ + assert 'vector' in obj.shape, f"normalize() requires 'vector' dim but got {type(obj)} with shape {shape(obj)}." + return math.normalize(obj, 'vector', epsilon, allow_infinite=allow_infinite, allow_zero=allow_zero) + + def line_trace(geo: Geometry, origin: Tensor, direction: Tensor, side='both', tolerance=None, max_iter=64, step_size=.9, max_line_length=None) -> Tuple[Tensor, Tensor, Tensor, Tensor, Optional[Tensor]]: """ Trace a line until it hits the surface of `geo`. From 1deca9888596edeec086608d8ac59682c6b219eb Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 25 Nov 2024 21:56:10 +0100 Subject: [PATCH 60/71] [geom] Move cross(), clip_length() to geom --- phi/field/_angular_velocity.py | 4 +- phi/flow.py | 7 ++-- phi/geom/__init__.py | 2 +- phi/geom/_functions.py | 70 +++++++++++++++++++++++++++++++--- phi/geom/_mesh.py | 16 ++++---- phi/geom/_sdf_grid.py | 11 +++--- phi/geom/_transform.py | 5 ++- 7 files changed, 87 insertions(+), 28 deletions(-) diff --git a/phi/field/_angular_velocity.py b/phi/field/_angular_velocity.py index 2a9f1a6b9..f87a16292 100644 --- a/phi/field/_angular_velocity.py +++ b/phi/field/_angular_velocity.py @@ -3,7 +3,7 @@ from phi import math from ._field import FieldInitializer, get_sample_points -from ..geom import Geometry +from ..geom import Geometry, cross from ..math import Shape, spatial, instance, Tensor, wrap, Extrapolation @@ -36,6 +36,6 @@ def _sample(self, geometry: Geometry, at: str, boundaries: Extrapolation, **kwar points = get_sample_points(geometry, at, boundaries) distances = points - self.location strength = self.strength if self.falloff is None else self.strength * self.falloff(distances) - velocity = math.cross_product(strength, distances) + velocity = cross(strength, distances) velocity = math.sum(velocity, self.location.shape.batch.without(points.shape)) return velocity diff --git a/phi/flow.py b/phi/flow.py index 818829bba..1cf6c41fe 100644 --- a/phi/flow.py +++ b/phi/flow.py @@ -40,12 +40,13 @@ dsum, isum, ssum, csum, mean, dmean, imean, smean, cmean, median, sign, round, ceil, floor, sqrt, exp, erf, log, log2, log10, sigmoid, soft_plus, sin, cos, tan, sinh, cosh, tanh, arcsin, arccos, arctan, arcsinh, arccosh, arctanh, log_gamma, factorial, incomplete_gamma, scatter, gather, where, nonzero, - cross_product as cross, dot, convolve, maximum, minimum, clip, # vector math + dot, convolve, maximum, minimum, clip, # vector math safe_div, is_finite, is_nan, is_inf, # Basic functions jit_compile, jit_compile_linear, minimize, gradient as functional_gradient, gradient, solve_linear, solve_nonlinear, iterate, identity, # jacobian, hessian, custom_gradient # Functional magic - assert_close, always_close, equal, close + assert_close, always_close, equal, close, + l1_loss, l2_loss, ) -from .geom import union, rotate, scale, length, normalize +from .geom import union, rotate, scale, length, normalize, cross from .vis import show, control, plot # Exceptions diff --git a/phi/geom/__init__.py b/phi/geom/__init__.py index 0598c5960..14c8fd063 100644 --- a/phi/geom/__init__.py +++ b/phi/geom/__init__.py @@ -13,7 +13,7 @@ # --- Low-level functions --- from ._geom import Geometry, GeometryException, Point, assert_same_rank, invert, sample_function -from ._functions import normal_from_slope +from ._functions import normal_from_slope, clip_length, cross from ._transform import scale, rotate, rotation_matrix, rotation_angles, rotation_matrix_from_axis_and_angle, rotation_matrix_from_directions # --- Geometry types --- diff --git a/phi/geom/_functions.py b/phi/geom/_functions.py index f4265ae76..47d9f9bd9 100644 --- a/phi/geom/_functions.py +++ b/phi/geom/_functions.py @@ -1,11 +1,69 @@ from typing import Sequence, Union -from phiml.math import Tensor, channel, Shape, vec_normalize, vec, sqrt, maximum, clip, vec_squared, vec_length, where, stack, dual, argmin, cross_product -from phiml.math._shape import parse_dim_order +from phiml import math +from phiml.math import Tensor, channel, Shape, vec_normalize, vec, sqrt, maximum, clip, vec_squared, vec_length, where, stack, dual, argmin, safe_div +from phiml.math._shape import parse_dim_order, DimFilter + # No dependence on Geometry +def cross(vec1: Tensor, vec2: Tensor) -> Tensor: + """ + Computes the cross product of two vectors in 2D. + + Args: + vec1: `Tensor` with a single channel dimension called `'vector'` + vec2: `Tensor` with a single channel dimension called `'vector'` + + Returns: + `Tensor` + """ + vec1 = math.tensor(vec1) + vec2 = math.tensor(vec2) + spatial_rank = vec1.vector.size if 'vector' in vec1.shape else vec2.vector.size + if spatial_rank == 2: # Curl in 2D + assert vec2.vector.exists + if vec1.vector.exists: + v1_x, v1_y = vec1.vector + v2_x, v2_y = vec2.vector + return v1_x * v2_y - v1_y * v2_x + else: + v2_x, v2_y = vec2.vector + return vec1 * stack([-v2_y, v2_x], channel(vec2)) + elif spatial_rank == 3: # Curl in 3D + assert vec1.vector.exists and vec2.vector.exists, f"Both vectors must have a 'vector' dimension but got shapes {vec1.shape}, {vec2.shape}" + v1_x, v1_y, v1_z = vec1.vector + v2_x, v2_y, v2_z = vec2.vector + return math.stack([ + v1_y * v2_z - v1_z * v2_y, + v1_z * v2_x - v1_x * v2_z, + v1_x * v2_y - v1_y * v2_x, + ], vec1.shape['vector']) + else: + raise AssertionError(f'dims = {spatial_rank}. Vector product not available in > 3 dimensions') + + +def clip_length(vec: Tensor, min_len=0, max_len=1, vec_dim: DimFilter = 'vector', eps: Union[float, Tensor] = 1e-5): + """ + Clips the length of a vector to the interval `[min_len, max_len]` while keeping the direction. + Zero-vectors remain zero-vectors. + + Args: + vec: `Tensor` + min_len: Lower clipping threshold. + max_len: Upper clipping threshold. + vec_dim: Dimensions to compute the length over. By default, all channel dimensions are used to compute the vector length. + eps: Minimum vector length. Use to avoid `inf` gradients for zero-length vectors. + + Returns: + `Tensor` with same shape as `vec`. + """ + le = math.length(vec, vec_dim, eps) + new_length = clip(le, min_len, max_len) + return vec * safe_div(new_length, le) + + def normal_from_slope(slope: Tensor, space: Union[str, Shape, Sequence[str]]): """ Computes the normal vector of a line, plane, or hyperplane. @@ -117,11 +175,11 @@ def closest_on_line(A, B, query): def closest_points_on_lines(p1, v1, p2, v2, eps=1e-10, can_be_parallel=True): """Find the closest points between two infinite lines defined by point and direction.""" - n = cross_product(v1, v2) + n = cross(v1, v2) n_norm = vec_normalize(n) diff = p2 - p1 - t1 = cross_product(v2, n_norm).vector @ diff.vector - t2 = cross_product(v1, n_norm).vector @ diff.vector + t1 = cross(v2, n_norm).vector @ diff.vector + t2 = cross(v1, n_norm).vector @ diff.vector c1, c2 = p1 + t1 * v1, p2 + t2 * v2 if can_be_parallel: is_parallel = vec_squared(n) < eps @@ -133,7 +191,7 @@ def closest_points_on_lines(p1, v1, p2, v2, eps=1e-10, can_be_parallel=True): def distance_line_point(line_offset: Tensor, line_direction: Tensor, point: Tensor, is_direction_normalized=False) -> Tensor: to_point = point - line_offset - c = vec_length(cross_product(to_point, line_direction)) + c = vec_length(cross(to_point, line_direction)) if not is_direction_normalized: c /= vec_length(line_direction) return c diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index 21c46d086..9c0d2f24c 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -9,20 +9,18 @@ from phiml import math from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d, non_dual, nonzero, stored_indices, stored_values, scatter, \ - find_closest, sqrt, where, vec_normalize, argmax, broadcast, cross_product, zeros, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ - assert_close, shift, pad, extrapolation, sum as sum_, flatten, dim_mask, math, cumulative_sum, arange + find_closest, sqrt, where, vec_normalize, argmax, broadcast, zeros, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ + assert_close, shift, pad, extrapolation, sum as sum_, flatten, dim_mask, math, Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, pairwise_distances, concat, Extrapolation from phiml.math._magic_ops import getitem_dataclass from phiml.math._sparse import CompactSparseTensor from phiml.math.extrapolation import as_extrapolation, PERIODIC from phiml.math.magic import slicing_dict -from . import bounding_box -from ._box import Box, BaseBox -from ._functions import plane_sgn_dist + from ._geom import Geometry, Point, NoGeometry +from ._box import Box, BaseBox, bounding_box +from ._functions import plane_sgn_dist, cross from ._graph import Graph, graph from ._transform import scale -from ..math import Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, \ - pairwise_distances, concat, Extrapolation @dataclass(frozen=True) @@ -309,7 +307,7 @@ def volume(self) -> Tensor: if isinstance(self.elements, CompactSparseTensor) and self.element_rank == 2: if instance(self.vertices).volume > 0: A, B, C, *_ = unstack(self.vertices.center[self.elements._indices], dual) - cross_area = vec_length(cross_product(B - A, C - A)) + cross_area = vec_length(cross(B - A, C - A)) fac = {3: 0.5, 4: 1}[dual(self.elements._indices).size] # tri, quad, ... return fac * cross_area else: @@ -328,7 +326,7 @@ def normals(self) -> Tensor: corners = self.vertices.center[{instance: three_vertices}] assert dual(corners).size == 3, f"signed distance currently only supports triangles" v1, v2, v3 = unstack(corners, dual) - return vec_normalize(cross_product(v2 - v1, v3 - v1)) + return vec_normalize(cross(v2 - v1, v3 - v1)) raise NotImplementedError @property diff --git a/phi/geom/_sdf_grid.py b/phi/geom/_sdf_grid.py index 062079308..8d5345b55 100644 --- a/phi/geom/_sdf_grid.py +++ b/phi/geom/_sdf_grid.py @@ -2,11 +2,12 @@ from typing import Union, Tuple, Dict, Any, Optional, Sequence from phiml import math -from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, non_channel, instance, stack, batch, dual, clip, wrap +from phiml.math import Shape, Tensor, spatial, channel, non_spatial, expand, instance, dual, clip, wrap from phiml.math.magic import slicing_dict -from . import UniformGrid from ._geom import Geometry -from ._box import Box, BaseBox, Cuboid +from ._functions import clip_length +from ._grid import UniformGrid +from ._box import BaseBox, Cuboid class SDFGrid(Geometry): @@ -287,7 +288,7 @@ def sample_sdf(geometry: Geometry, volume = geometry.volume bounding_radius = geometry.bounding_radius() rebuild = None if rebuild == 'auto' else rebuild - if cache_surface: + if cache_surface or rebuild is not None: sdf, delta, normal, _, idx = geometry.approximate_closest_surface(points) approximate = SDFGrid(sdf, bounds, approximate_outside, None, delta, normal, idx, center=center, volume=volume, bounding_radius=bounding_radius) else: @@ -326,7 +327,7 @@ def refine_closest(sample_points, closest, refine: Geometry, max_step, steps=10) for ref_step in range(steps): sgn_dist, delta, normal, offset, _ = refine.approximate_closest_surface(closest) tang_proj = sample_points - normal * (normal.vector @ sample_points.vector - offset) - walk_on_surface = math.clip_length(tang_proj - closest, 0, max_step * min(1, .5 ** (ref_step - steps / 2))) + walk_on_surface = clip_length(tang_proj - closest, 0, max_step * min(1, .5 ** (ref_step - steps / 2))) better_closest = (closest + delta) + walk_on_surface closest = math.where(refine.lies_inside(better_closest), closest, better_closest) # don't walk into negative SDF # trj.append(closest) diff --git a/phi/geom/_transform.py b/phi/geom/_transform.py index c70b42a52..d256c0f8b 100644 --- a/phi/geom/_transform.py +++ b/phi/geom/_transform.py @@ -1,9 +1,10 @@ from typing import Optional from phiml import math -from phiml.math import Tensor, channel, rename_dims, wrap, shape, normalize, cross_product, dual, stack, length +from phiml.math import Tensor, channel, rename_dims, wrap, shape, normalize, dual, stack, length from ._geom import Geometry, GeometricType +from ._functions import cross def scale(obj: GeometricType, scale: float | Tensor, pivot: Tensor = None, dim='vector') -> GeometricType: @@ -155,7 +156,7 @@ def rotation_matrix_from_directions(source_dir: Tensor, target_dir: Tensor, vec_ if source_dir.vector.size == 3: source_dir = normalize(source_dir, vec_dim, epsilon=epsilon) target_dir = normalize(target_dir, vec_dim, epsilon=epsilon) - axis = cross_product(source_dir, target_dir) + axis = cross(source_dir, target_dir) lim = 1-epsilon if epsilon is not None else 1 angle = math.arccos(math.clip(source_dir.vector @ target_dir.vector, -lim, lim)) return rotation_matrix_from_axis_and_angle(axis, angle, is_axis_normalized=False, epsilon=epsilon) From 4659a1483aa6bf38451b669e7578e1d5bbdfc657 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 25 Nov 2024 22:34:59 +0100 Subject: [PATCH 61/71] [physics] Fix MacCormack advection --- phi/physics/advect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/physics/advect.py b/phi/physics/advect.py index 1101939d8..a535745bc 100644 --- a/phi/physics/advect.py +++ b/phi/physics/advect.py @@ -200,7 +200,7 @@ def mac_cormack(field: Field, Returns: Advected field of type `type(field)` """ - v0 = resample(velocity, to=field).values + v0 = sample(velocity, field.geometry, at=field.sampled_at, boundary=field.boundary) points_bwd = integrator(field, velocity, -dt, v0=v0) points_fwd = integrator(field, velocity, dt, v0=v0) # --- forward+backward semi-Lagrangian advection --- From 868cc8b224e219699d7ce2e7cbe62d5be73c3560 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 27 Nov 2024 12:09:30 +0100 Subject: [PATCH 62/71] =?UTF-8?q?[=CE=A6]=20Require=20PhiML=201.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 979c84a47..3147f4001 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ url='https://github.com/tum-pbs/PhiFlow', include_package_data=True, install_requires=[ - 'phiml>=1.9.0', + 'phiml>=1.10.0', 'matplotlib>=3.5.0', # also required by dash for color maps 'packaging', ], From f212906d13410ac4f6755db2ab80763b3199eeef Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 27 Nov 2024 12:23:17 +0100 Subject: [PATCH 63/71] [field] Minor fix for PhiML 1.10 --- phi/field/_field.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/field/_field.py b/phi/field/_field.py index 0db9ab6f8..c7ef9c94a 100644 --- a/phi/field/_field.py +++ b/phi/field/_field.py @@ -472,7 +472,7 @@ def with_bounds(self, bounds: Box): order = list(bounds.vector.item_names) geometry = self.geometry.vector[order] new_shape = self.values.shape.without(order) & self.values.shape.only(order, reorder=True) - values = math.transpose(self.values, new_shape) + values = math.swap_axes(self.values, new_shape) return Field(geometry, values, self.boundary) def with_geometry(self, elements: Geometry): From d71843e3f573c251f6ff8d3d0989e9cd424753a4 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Wed, 27 Nov 2024 12:25:06 +0100 Subject: [PATCH 64/71] [geom] Add load_stl() docstring --- phi/geom/_mesh.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index 9c0d2f24c..33e3eab10 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -539,7 +539,17 @@ def load_gmsh(file: str, boundary_names: Sequence[str] = None, periodic: str = N @broadcast -def load_stl(file: str, face_dim=instance('faces')): +def load_stl(file: str, face_dim=instance('faces')) -> Mesh: + """ + Load a triangle `Mesh` from an STL file. + + Args: + file: File path to `.stl` file. + face_dim: Instance dim along which to list the triangles. + + Returns: + `Mesh` with `spatial_rank=3` and `element_rank=2`. + """ import stl model = stl.mesh.Mesh.from_file(file) points = np.reshape(model.points, (-1, 3)) From e09458f0bbc22072053416d5d5994f506c0679cf Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 12:39:22 +0100 Subject: [PATCH 65/71] [geom] Update Mesh face analysis --- phi/geom/_mesh.py | 152 +++++++++++++++++++++++----------------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/phi/geom/_mesh.py b/phi/geom/_mesh.py index 33e3eab10..2d8da9328 100644 --- a/phi/geom/_mesh.py +++ b/phi/geom/_mesh.py @@ -10,7 +10,8 @@ from phiml import math from phiml.math import to_format, is_sparse, non_channel, non_batch, batch, pack_dims, unstack, tensor, si2d, non_dual, nonzero, stored_indices, stored_values, scatter, \ find_closest, sqrt, where, vec_normalize, argmax, broadcast, zeros, EMPTY_SHAPE, meshgrid, mean, reshaped_numpy, range_tensor, convolve, \ - assert_close, shift, pad, extrapolation, sum as sum_, flatten, dim_mask, math, Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, stack, vec_length, tensor_like, pairwise_distances, concat, Extrapolation + assert_close, shift, pad, extrapolation, sum as sum_, dim_mask, math, Tensor, Shape, channel, shape, instance, dual, rename_dims, expand, spatial, wrap, sparse_tensor, \ + stack, vec_length, tensor_like, pairwise_distances, concat, Extrapolation, dsum, reshaped_tensor, dmean from phiml.math._magic_ops import getitem_dataclass from phiml.math._sparse import CompactSparseTensor from phiml.math.extrapolation import as_extrapolation, PERIODIC @@ -99,7 +100,7 @@ def face_normals(self) -> Tensor: @cached_property def _faces(self) -> Dict[str, Any]: if self.element_rank == 2: - centers, normals, areas, boundary_slices = build_faces_2d(self.vertices.center, self.elements, self.boundaries, self.periodic, self._vertex_mean, self.face_format) + centers, normals, areas, boundary_slices = build_faces(self.vertices.center, self.elements, self.boundaries, self.element_rank, self.periodic, self._vertex_mean, self.face_format) return { 'center': centers, 'normal': normals, @@ -651,12 +652,13 @@ def mesh(vertices: Geometry | Tensor, return Mesh(vertices, elements, element_rank, boundaries, periodic_dims, face_format=face_format, max_cell_walk=max_cell_walk) -def build_faces_2d(vertices: Tensor, # (vertices:i, vector) - elements: Tensor, # (elements:i, ~vertices) - boundaries: Dict[str, Sequence], # vertex pairs - periodic: Sequence[str], # periodic dim names - vertex_mean: Tensor, - face_format: str): +def build_faces(vertices: Tensor, # (vertices:i, vector) + elements: Tensor, # (elements:i, ~vertices) + boundaries: Dict[str, Sequence], # vertex pairs + element_rank: int, + periodic: Sequence[str], # periodic dim names + vertex_mean: Tensor, + face_format: str): """ Given a list of vertices, elements and boundary edges, computes the element connectivity matrix and corresponding edge properties. @@ -664,84 +666,82 @@ def build_faces_2d(vertices: Tensor, # (vertices:i, vector) vertices: `Tensor` representing list (instance) of vectors (channel) elements: Sparse matrix listing all elements (instance). Each entry represents a vertex (dual) belonging to an element. boundaries: Named sequences of edges (vertex pairs). + element_rank: Spatial rank of the elements (currently only 2 is supported) periodic: Which dims are periodic. vertex_mean: Mean vertex position for each element. face_format: Sparse matrix format to use for the element-element matrices. """ - cell_dim = instance(elements).name - nb_dim = instance(elements).as_dual().name - boundaries = {k: wrap(v, 'line:i,vert:i=(start,end)') for k, v in boundaries.items()} - # --- Periodic: map duplicate vertices to the same index --- + n_v = instance(vertices).size + n_e = instance(elements).size + # --- Periodic: map vertices of boundary+ to the corresponding vertex in boundary- --- vertex_id = np.arange(instance(vertices).size) for dim in periodic: - lo_idx, up_idx = boundaries[dim+'-'], boundaries[dim+'+'] - for lo_i, up_i in zip(set(flatten(lo_idx)), set(flatten(up_idx))): - vertex_id[up_i] = lo_i # map periodic vertices to one index - el_coo = to_format(elements, 'coo').numpy().astype(np.int32) - el_coo.col = vertex_id[el_coo.col] - # --- Add virtual boundary elements for non-periodic boundaries --- + vertex_id[np.concatenate(boundaries[dim+'+'])] = np.concatenate(boundaries[dim+'-']) + is_periodic = dim_mask(vertices.vector.item_names, periodic) + # --- element-facet and facet-vertex matrix. A facet describes a single oriented face of an element, i.e. shared faces get two entries. --- + if element_rank == 2: # edges are the lines between neighbor vertices in the vertex lists + the edge last-to-first + v1 = stored_indices(elements).index[dual(elements).name].numpy() + v1 = vertex_id[v1] + n_f = v1.size # total number of facets (excluding boundaries) + f_count = dsum(elements).numpy() # #facets per element + ptr = np.cumsum(f_count) + roll = np.arange(v1.size) + 1 + roll[ptr - 1] = ptr - f_count + v12 = np.stack([v1, v1[roll]], -1).flatten() + f_idx = np.arange(v1.size, dtype=v1.dtype) + f_idx2 = f_idx.repeat(2) + f_v = coo_matrix((np.ones(n_f*2, np.int32), (f_idx2, v12)), shape=(n_f, n_v)) # facet-vertex matrix + e_idx = np.arange(instance(elements).size).repeat(f_count) + e_f = coo_matrix((np.ones(n_f, bool), (e_idx, f_idx)), shape=(n_e, n_f)) # element-facet matrix + # --- Compute facet properties: center, normal, area --- + f_v_pos = vertices[reshaped_tensor(v12, [instance('facets') + dual(pair=2)])] # vertex positions of every (inner) facet + if periodic: # map v_pos: closest to cell_center + cell_center = vertex_mean[wrap(e_idx, 'facets:i')] + bounds = bounding_box(vertices) + delta = PERIODIC.shortest_distance(cell_center - bounds.lower, f_v_pos - bounds.lower, bounds.size) + f_v_pos = where(is_periodic, cell_center + delta, f_v_pos) + edge_center = dmean(f_v_pos) + edge_dir = f_v_pos.pair.dual[1] - f_v_pos.pair.dual[0] + edge_len = vec_length(edge_dir) + normal = vec_normalize(stack([-edge_dir[1], edge_dir[0]], channel(edge_dir))) + else: + raise NotImplementedError("Only 2D Mesh faces are currently supported") + # e_v = to_format(elements, 'coo').numpy().astype(np.int32) + # e_v.col = vertex_id[e_v.col] + # e_f = coo_matrix(...) + # f_v = coo_matrix(...) + # f_v_pos = vertices[...] + # --- Add virtual boundary elements to f_v for non-periodic boundaries --- boundary_slices = {} - end = instance(elements).size - bnd_coo_idx, bnd_coo_vert = [el_coo.row], [el_coo.col] + e_end, f_end = e_f.shape + b_idx_f, b_idx_v = [f_v.row], [f_v.col] for bnd_key, bnd_vertices in boundaries.items(): if bnd_key[:-1] in periodic: continue - bnd_vert = bnd_vertices.numpy(['line,vert']) - bnd_idx = np.arange(bnd_vertices.line.size).repeat(2) + end - bnd_coo_idx.append(bnd_idx) - bnd_coo_vert.append(bnd_vert) - boundary_slices[bnd_key] = {nb_dim: slice(end, end+bnd_vertices.line.size)} - end += bnd_vertices.line.size - bnd_coo_idx = np.concatenate(bnd_coo_idx) - bnd_coo_vert = vertex_id[np.concatenate(bnd_coo_vert)] - bnd_el_coo = coo_matrix((np.ones((bnd_coo_idx.size,), dtype=bool), (bnd_coo_idx, bnd_coo_vert)), shape=(end, instance(vertices).size)) - # --- Compute neighbor elements --- - num_shared_vertices: csr_matrix = el_coo @ bnd_el_coo.T - neighbor_filter, = np.where(num_shared_vertices.data == 2) - src_cell, nb_cell = num_shared_vertices.nonzero() - src_cell = src_cell[neighbor_filter] - nb_cell = nb_cell[neighbor_filter] - connected_elements_coo = coo_matrix((np.ones(src_cell.size, dtype=bool), (src_cell, nb_cell)), shape=num_shared_vertices.shape) - element_connectivity = wrap(connected_elements_coo, instance(elements).without_sizes() & dual) - element_connectivity = to_format(element_connectivity, face_format) - # --- Find vertices for each face pair using 4 alternating patterns: [0101...], [1010...], ["]+[010...], [101...]+["] --- - bnd_el_coo_v_idx = coo_matrix((bnd_coo_vert+1, (bnd_coo_idx, bnd_coo_vert)), shape=(end, instance(vertices).size)) - ptr = np.cumsum(np.asarray(el_coo.sum(1))) - first_ptr = np.pad(ptr, (1, 0))[:-1] - alt1 = np.arange(el_coo.data.size) % 2 - alt2 = (1 - alt1) - alt2[first_ptr] = alt1[first_ptr] - alt3 = (1 - alt1) - alt3[ptr - 1] = alt1[ptr - 1] - v_indices = [] - for alt in [alt1, (1-alt1), alt2, alt3]: - el_coo.data = alt + 1e-10 - alt_v_idx = (el_coo @ bnd_el_coo_v_idx.T) - v_indices.append(alt_v_idx.data[neighbor_filter].astype(np.int32)) - v_indices = np.sort(np.stack(v_indices, -1), axis=1) - 1 - # Cases: 0,i1,i2 | i1,i1,i2 | i1,i2,i2 | i1,i2,i1+i2 (0 is invalid, doubles are invalid) - # For [1-3]: If self > left and left != 0 and it is the first -> this is the second element. - first_index = np.argmax((v_indices[:, 1:] > v_indices[:, :-1]) & (v_indices[:, :-1] >= 0), 1) - v_indices = v_indices[np.arange(v_indices.shape[0]), np.stack([first_index, first_index+1])] - v_indices = wrap(v_indices, 'vert:i=(start,end),edge:i') - v_pos = vertices[v_indices] - if periodic: # map v_pos: closest to cell_center - cell_center = vertex_mean[wrap(src_cell, 'edge:i')] - bounds = bounding_box(vertices) - delta = PERIODIC.shortest_distance(cell_center - bounds.lower, v_pos - bounds.lower, bounds.size) - is_periodic = dim_mask(vertices.vector.item_names, periodic) - v_pos = where(is_periodic, cell_center + delta, v_pos) - # --- Compute face information --- - edge_dir = v_pos.vert['end'] - v_pos.vert['start'] - edge_center = .5 * (v_pos.vert['end'] + v_pos.vert['start']) - edge_len = vec_length(edge_dir) - normal = vec_normalize(stack([-edge_dir[1], edge_dir[0]], channel(edge_dir))) - # --- Wrap in sparse matrices --- - indices = wrap(np.stack([src_cell, nb_cell]), channel(index=(cell_dim, nb_dim)), 'edge:i') - edge_len = sparse_tensor(indices, edge_len, element_connectivity.shape, format='coo' if face_format == 'dense' else face_format, indices_constant=True) - normal = tensor_like(edge_len, normal, value_order='original') - edge_center = tensor_like(edge_len, edge_center, value_order='original') - return edge_center, normal, edge_len, boundary_slices + v_count = np.asarray([len(vs) for vs in bnd_vertices]) + v_idx = np.concatenate(bnd_vertices) + f_idx = np.arange(len(bnd_vertices)).repeat(v_count) + f_end + b_idx_f.append(f_idx) + b_idx_v.append(v_idx) + boundary_slices[bnd_key] = {instance(elements).as_dual().name: slice(e_end, e_end+len(bnd_vertices))} + f_end += len(bnd_vertices) + e_end += len(bnd_vertices) + b_idx_f = np.concatenate(b_idx_f) + b_idx_v = vertex_id[np.concatenate(b_idx_v)] + f_v_b = coo_matrix((np.ones(b_idx_f.size, bool), (b_idx_f, b_idx_v)), shape=(f_end, n_v)) + # --- Add virtual boundary facets to e_f --- + e_f_be = np.concatenate([e_f.row, np.arange(n_e, e_end)]) + e_f_bf = np.arange(e_f_be.size) # every face assigned to exactly one element. Identical to np.concatenate([e_f.col, np.arange(n_f, f_end)]) + e_f_b = coo_matrix((np.ones(e_f_bf.size, bool), (e_f_be, e_f_bf)), shape=(e_end, f_end)) + # --- Compute connectivity and return element-pair facet properties --- + f_f: csr_matrix = f_v @ f_v_b.T >= element_rank # symmetric + f_f.setdiag(0) + f_f.eliminate_zeros() + f_f.data = np.arange(1, n_f+1) # equal to f_f.nonzero()[0] + e_e = e_f @ f_f @ e_f_b.T # stores the outgoing facet_index+1 for each element pair + shared_edge = wrap(e_e, instance(elements).without_sizes() & dual) - 1 + shared_edge = to_format(shared_edge, face_format) + return edge_center[shared_edge], normal[shared_edge], edge_len[shared_edge], boundary_slices def build_mesh(bounds: Box = None, From e7e2cd024bd434150ad776fed32fa4cd19006941 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 13:20:51 +0100 Subject: [PATCH 66/71] [doc] Update Fluid_Simulation.ipynb --- docs/Fluid_Simulation.ipynb | 54 +++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/Fluid_Simulation.ipynb b/docs/Fluid_Simulation.ipynb index 35d254323..1af6c9913 100644 --- a/docs/Fluid_Simulation.ipynb +++ b/docs/Fluid_Simulation.ipynb @@ -27,6 +27,12 @@ { "cell_type": "code", "execution_count": null, + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + }, "outputs": [], "source": [ "from tqdm.notebook import trange\n", @@ -34,16 +40,16 @@ "\n", "velocity = StaggeredGrid(Noise(), 'periodic', x=64, y=96)\n", "plot({\"velocity\": velocity, \"vorticity\": field.curl(velocity)})" - ], + ] + }, + { + "cell_type": "markdown", "metadata": { "collapsed": false, "pycharm": { - "name": "#%%\n" + "name": "#%% md\n" } - } - }, - { - "cell_type": "markdown", + }, "source": [ "## Operator Splitting\n", "\n", @@ -58,17 +64,17 @@ "\n", "All of these functions take in a state variable and return the new state after a certain time `dt` has passed.\n", "In the following example, the velocity is self-advected and made incompressible, while the marker is passively advected." - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%% md\n" - } - } + ] }, { "cell_type": "code", "execution_count": null, + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + }, "outputs": [], "source": [ "@jit_compile\n", @@ -81,13 +87,7 @@ "velocity0, pressure0 = fluid.make_incompressible(velocity)\n", "velocity1, pressure1 = operator_split_step(velocity0, None, dt=1.)\n", "plot({'initial vorticity': field.curl(velocity0), 'after step': field.curl(velocity1)})" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } + ] }, { "cell_type": "code", @@ -110,7 +110,7 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ "
" ] @@ -2903,7 +2903,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": { "pycharm": { "is_executing": true, @@ -5950,8 +5950,10 @@ "def rk4_step(v, p, dt):\n", " return fluid.incompressible_rk4(momentum_equation, v, p, dt, pressure_order=4)\n", "\n", - "velocity_trj, pressure_trj = iterate(rk4_step, batch(time=200), velocity0, pressure0, dt=.5, range=trange)\n", - "plot(field.curl(velocity_trj.time[::2]), animate='time', same_scale=False)" + "velocity = CenteredGrid(Noise(vector='x,y'), 'periodic', x=64, y=96)\n", + "velocity0, pressure0 = fluid.make_incompressible(velocity, order=4)\n", + "velocity_trj, pressure_trj = iterate(rk4_step, batch(time=100), velocity0, pressure0, dt=.5, substeps=2, range=trange)\n", + "plot(field.curl(velocity_trj), animate='time', same_scale=False)" ] }, { @@ -5974,7 +5976,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "phiflow-projects", "language": "python", "name": "python3" }, @@ -5993,4 +5995,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} From c716dcaa7a047940d0538af641adfc60baf4ee9d Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 13:24:04 +0100 Subject: [PATCH 67/71] [field] Fix FieldEmbedding.pad_values() https://github.com/tum-pbs/PhiFlow/issues/179 --- phi/field/_embed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/field/_embed.py b/phi/field/_embed.py index d25544a34..13b746731 100644 --- a/phi/field/_embed.py +++ b/phi/field/_embed.py @@ -53,7 +53,7 @@ def is_face_valid(self, key) -> bool: return False def pad_values(self, value: Tensor, width: int, dim: str, upper_edge: bool, bounds: Box = None, already_padded: dict = None, **kwargs) -> Tensor: - assert bounds is not None, f"{type(self)}.pad() requires 'bounds' argument" + assert bounds is not None or (already_padded and not value.shape.is_non_uniform), f"{type(self)}.pad() requires 'bounds' argument" if value.shape.is_non_uniform: unstacked = unstack(value, value.shape.non_uniform_shape) indices = value.shape.non_uniform_shape.meshgrid(names=True) From b1fa45c683164b6d72f02930f44ed95307d73cd2 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 13:43:15 +0100 Subject: [PATCH 68/71] [doc] Update Wake_Flow.ipynb --- examples/grids/Wake_Flow.ipynb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/grids/Wake_Flow.ipynb b/examples/grids/Wake_Flow.ipynb index 63f137a23..bbf12e117 100644 --- a/examples/grids/Wake_Flow.ipynb +++ b/examples/grids/Wake_Flow.ipynb @@ -15,13 +15,14 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%pip install phiflow\n", - "from phi.jax.flow import *\n", - "# from phi.flow import * # If JAX is not installed. You can use phi.torch or phi.tf as well." + "from phi.torch.flow import *\n", + "# from phi.flow import * # If JAX is not installed. You can use phi.torch or phi.tf as well.\n", + "from tqdm.notebook import trange" ] }, { @@ -75,7 +76,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -86,7 +87,8 @@ "\n", "boundary = {'x-': vec(x=2, y=0, z=0), 'x+': ZERO_GRADIENT, 'y': PERIODIC, 'z': PERIODIC}\n", "v0 = StaggeredGrid((8., 0, 0), boundary, x=128, y=64, z=8, bounds=Box(x=200, y=100, z=5))\n", - "v_trj, p_trj = iterate(step, batch(time=200), v0, None)" + "v0, p0 = fluid.make_incompressible(v0, cylinder, Solve('scipy-direct'))\n", + "v_trj, p_trj = iterate(step, batch(time=200), v0, p0, range=trange)" ] }, { From d6c762a8610dc6275ce97072d3f37fe5d3f38543 Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 17:55:10 +0100 Subject: [PATCH 69/71] [vis] Fix Plotly cmap interpolation --- phi/vis/_dash/_plotly_plots.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/phi/vis/_dash/_plotly_plots.py b/phi/vis/_dash/_plotly_plots.py index ec84e8084..c6048d4f3 100644 --- a/phi/vis/_dash/_plotly_plots.py +++ b/phi/vis/_dash/_plotly_plots.py @@ -770,8 +770,10 @@ def get_color_interpolation(val, cm_arr): center = cm_arr[cm_arr[:, 0] == val][-1] else: offset_positions = cm_arr[:, 0] - val - color1 = cm_arr[numpy.argmax(offset_positions[offset_positions < 0])] # largest value smaller than control - color2 = cm_arr[numpy.argmin(offset_positions[offset_positions > 0])] # smallest value larger than control + below = offset_positions[offset_positions < 0] + color1 = cm_arr[numpy.argmax(below)] if below.size > 0 else cm_arr[0] # largest value smaller than control + above = offset_positions[offset_positions > 0] + color2 = cm_arr[numpy.argmin(above)] if above.size > 0 else cm_arr[-1] # smallest value larger than control if color1[0] == color2[0]: center = color1 else: From 308171ece646de1735b3c7eb95252b64644dbf8e Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Mon, 2 Dec 2024 13:09:02 +0100 Subject: [PATCH 70/71] [field] Fix deprecated Hard/SoftGeometryMask --- phi/field/_mask.py | 17 ++++++++--------- tests/commit/field/test__mask.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 tests/commit/field/test__mask.py diff --git a/phi/field/_mask.py b/phi/field/_mask.py index b36f1b9f6..7a49bb348 100644 --- a/phi/field/_mask.py +++ b/phi/field/_mask.py @@ -3,24 +3,23 @@ from phi import math from phi.geom import Geometry -from ._field import Field -from phiml.math import Tensor +from ._field import FieldInitializer +from phiml.math import Tensor, Extrapolation -class HardGeometryMask(Field): +class HardGeometryMask(FieldInitializer): """ - Deprecated since version 1.3. Use `phi.field.mask()` or `phi.field.resample()` instead. + Deprecated since version 2.3. Use `phi.field.mask()` or `phi.field.resample()` instead. """ - def __init__(self, geometry: Geometry): - super().__init__(geometry, 1, 0) + self.geometry = geometry warnings.warn("HardGeometryMask and SoftGeometryMask are deprecated. Use field.mask or field.resample instead.", DeprecationWarning, stacklevel=2) @property def shape(self): return self.geometry.shape.non_channel - def _sample(self, geometry: Geometry, **kwargs) -> Tensor: + def _sample(self, geometry: Geometry, at: str, boundaries: Extrapolation, **kwargs) -> math.Tensor: return math.to_float(self.geometry.lies_inside(geometry.center)) def __getitem__(self, item: dict): @@ -29,14 +28,14 @@ def __getitem__(self, item: dict): class SoftGeometryMask(HardGeometryMask): """ - Deprecated since version 1.3. Use `phi.field.mask()` or `phi.field.resample()` instead. + Deprecated since version 2.3. Use `phi.field.mask()` or `phi.field.resample()` instead. """ def __init__(self, geometry: Geometry, balance: Union[Tensor, float] = 0.5): warnings.warn("HardGeometryMask and SoftGeometryMask are deprecated. Use field.mask or field.resample instead.", DeprecationWarning, stacklevel=2) super().__init__(geometry) self.balance = balance - def _sample(self, geometry: Geometry, **kwargs) -> Tensor: + def _sample(self, geometry: Geometry, at: str, boundaries: Extrapolation, **kwargs) -> math.Tensor: return self.geometry.approximate_fraction_inside(geometry, self.balance) def __getitem__(self, item: dict): diff --git a/tests/commit/field/test__mask.py b/tests/commit/field/test__mask.py new file mode 100644 index 000000000..f1871cc00 --- /dev/null +++ b/tests/commit/field/test__mask.py @@ -0,0 +1,20 @@ +from unittest import TestCase + +from phiml import math +from phi.geom import Sphere +from phi.physics._boundaries import Domain +from phi.field import * + + +class TestNoise(TestCase): + + def test_masks(self): + domain = Domain(x=10, y=10) + sphere = Sphere(x=5, y=5, radius=2) + hard_v = domain.staggered_grid(HardGeometryMask(sphere)) + hard_s = domain.grid(HardGeometryMask(sphere)) + soft_v = domain.staggered_grid(SoftGeometryMask(sphere)) + soft_s = domain.grid(SoftGeometryMask(sphere)) + for f in [hard_v, hard_s, soft_v, soft_s]: + math.assert_close(1, f.values.max) + math.assert_close(0, f.values.min) From 0e5b57c4c2974dcfe2cb35269ebadfa8ac9d33ef Mon Sep 17 00:00:00 2001 From: Philipp Holl Date: Fri, 29 Nov 2024 13:43:40 +0100 Subject: [PATCH 71/71] =?UTF-8?q?[=CE=A6]=20Update=20version=20to=203.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phi/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phi/VERSION b/phi/VERSION index a0cd9f0cc..a4f52a5db 100644 --- a/phi/VERSION +++ b/phi/VERSION @@ -1 +1 @@ -3.1.0 \ No newline at end of file +3.2.0 \ No newline at end of file