@@ -189,9 +189,6 @@ def _save_points_to_sdata(
189189 raise ValueError ("Cannot export a points element with no points" )
190190 transformed_data = np .array ([layer_to_save .data_to_world (xy ) for xy in layer_to_save .data ])
191191 swap_data = np .fliplr (transformed_data )
192- # ignore z axis if present
193- if swap_data .shape [1 ] == 3 :
194- swap_data = swap_data [:, :2 ]
195192 parsed = PointsModel .parse (swap_data , transformations = transformation )
196193
197194 # saving to disk of points temporarily disabled until the interface update that will unify the view widget,
@@ -261,14 +258,21 @@ def _save_shapes_to_sdata(
261258 for shape in layer_to_save ._data_view .shapes
262259 ]
263260
264- def _fix_coords (coords : ArrayLike ) -> ArrayLike :
265- remove_z = coords .shape [1 ] == 3
266- first_index = 1 if remove_z else 0
267- coords = coords [:, first_index ::]
268- return np .fliplr (coords )
261+ has_z = coords [0 ].shape [1 ] == 3
269262
270- polygons : list [Polygon ] = [Polygon (_fix_coords (p )) for p in coords ]
271- gdf = GeoDataFrame ({"geometry" : polygons })
263+ def _fix_coords (coords : ArrayLike ) -> tuple [ArrayLike , float | None ]:
264+ if coords .shape [1 ] == 3 :
265+ z_val = float (coords [0 , 0 ])
266+ yx = coords [:, 1 :]
267+ return np .fliplr (yx ), z_val
268+ return np .fliplr (coords ), None
269+
270+ fixed = [_fix_coords (p ) for p in coords ]
271+ polygons : list [Polygon ] = [Polygon (xy ) for xy , _ in fixed ]
272+ gdf_dict : dict [str , Any ] = {"geometry" : polygons }
273+ if has_z :
274+ gdf_dict ["z" ] = [z_val for _ , z_val in fixed ]
275+ gdf = GeoDataFrame (gdf_dict )
272276
273277 force_2d (gdf )
274278 parsed = ShapesModel .parse (gdf , transformations = transformation )
@@ -514,11 +518,15 @@ def get_sdata_circles(self, sdata: SpatialData, key: str, selected_cs: str, mult
514518 original_name = original_name [: original_name .rfind ("_" )]
515519
516520 df = sdata .shapes [original_name ]
517- affine = _get_transform (sdata .shapes [original_name ], selected_cs )
521+ axes = get_axes_names (df )
522+ include_z = "z" in axes and not config .PROJECT_2_5D_SHAPES_TO_2D
523+ affine = _get_transform (sdata .shapes [original_name ], selected_cs , include_z = include_z )
518524
519- # 2.5D circles not supported yet
520525 xy = np .array ([df .geometry .x , df .geometry .y ]).T
521526 yx = np .fliplr (xy )
527+ if include_z :
528+ z_vals = df ["z" ].to_numpy ()
529+ yx = np .column_stack ([z_vals , yx ])
522530 radii = df .radius .to_numpy ()
523531
524532 adata , table_name , table_names = self ._get_table_data (sdata , original_name )
@@ -561,7 +569,7 @@ def get_sdata_circles(self, sdata: SpatialData, key: str, selected_cs: str, mult
561569 else :
562570 kwargs |= {"border_color" : "white" }
563571 # useful code to have readily available to debug the correct radius of circles when represented as points
564- ellipses = _get_ellipses_from_circles (yx = yx , radii = radii )
572+ ellipses = _get_ellipses_from_circles (coords = yx , radii = radii )
565573 layer = Shapes (
566574 ellipses ,
567575 shape_type = "ellipse" ,
@@ -804,8 +812,43 @@ def _affine_transform_layers(self, coordinate_system: str) -> None:
804812 sdata = metadata ["sdata" ]
805813 element_name = metadata ["name" ]
806814 element_data = sdata [element_name ]
807- affine = _get_transform (element_data , coordinate_system )
815+ include_z = self ._should_include_z (element_data )
816+ affine = _get_transform (element_data , coordinate_system , include_z = include_z )
808817 if affine is not None :
809818 layer .affine = affine
810819 if layer ._type_string == "points" :
811820 self ._adjust_radii_of_points_layer (layer , affine )
821+
822+ @staticmethod
823+ def _has_z_axis (element : Any ) -> bool :
824+ """Return ``True`` if ``element`` exposes a ``z`` axis.
825+
826+ For raster elements (images / labels) the ``z`` axis is reported by
827+ :func:`spatialdata.models.get_axes_names`. For vector elements (points
828+ as :class:`~dask.dataframe.DataFrame`, shapes as
829+ :class:`~geopandas.GeoDataFrame`) the same helper is used.
830+ """
831+ from xarray import DataArray , DataTree
832+
833+ if not isinstance (element , DataArray | DataTree | DaskDataFrame | GeoDataFrame ):
834+ return False
835+ return "z" in get_axes_names (element )
836+
837+ @staticmethod
838+ def _should_include_z (element : DaskDataFrame | GeoDataFrame ) -> bool :
839+ """Determine whether to include the z axis for a given spatial element.
840+
841+ For raster data (images, labels) z is always included when present.
842+ For vector data (points, shapes) z inclusion depends on the user-facing
843+ projection config flags.
844+ """
845+ from xarray import DataArray , DataTree
846+
847+ if isinstance (element , DataArray | DataTree ):
848+ return True
849+ axes = get_axes_names (element )
850+ if "z" not in axes :
851+ return False
852+ if isinstance (element , DaskDataFrame ):
853+ return not config .PROJECT_3D_POINTS_TO_2D
854+ return not config .PROJECT_2_5D_SHAPES_TO_2D
0 commit comments