Repository: Deltares/xugrid (language: python)

Issue:
from_structured: rotated grids
Apparently QGIS still has lacklustre support for rotated rasters.

These are also easily represented by an unstructured grid: we just need to take the rotation into account when generating the corner vertices.


Discussion / hints:

Fix patch:
diff --git a/docs/api.rst b/docs/api.rst
index a8b9730bc..a108a5faa 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -329,7 +329,10 @@ UGRID2D Topology
     Ugrid2d.to_dataset
     Ugrid2d.from_geodataframe
     Ugrid2d.from_structured
+    Ugrid2d.from_structured_multicoord
     Ugrid2d.from_structured_bounds
+    Ugrid2d.from_structured_intervals1d
+    Ugrid2d.from_structured_intervals2d
     Ugrid2d.from_shapely
     Ugrid2d.to_shapely
 
diff --git a/docs/changelog.rst b/docs/changelog.rst
index cd968e264..cd7c2fb13 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,6 +6,27 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_, and this project adheres to
 `Semantic Versioning`_.
 
+Unreleased
+----------
+
+Added
+~~~~~
+
+- :meth:`xugrid.Ugrid2.from_structured_multicoord` has been added
+  to generate UGRID topologies from rotated or approximated curvilinear grids.
+- :meth:`xugrid.Ugrid2d.from_structured_intervals1d` has been added to generate
+  UGRID topologies from "intervals": the N + 1 vertex coordinates for N faces.
+- :meth:`xugrid.Ugrid2d.from_structured_intervals2d` has been added to generate
+  UGRID topologies from "intervals": the (M + 1, N + 1) vertex coordinates for N faces.
+- :meth:`xugrid.UgridDataArrayAccessor.from_structured` now takes ``x`` and ``y``
+  arguments to specify which coordinates to use as the UGRID x and y coordinates.
+
+Changed
+~~~~~~~
+
+- :meth:`xugrid.Ugrid2d.from_structured` now takes ``x`` and ``y`` arguments instead
+  of ``x_bounds`` and ``y_bounds`` arguments.
+
 [0.8.1] 2024-01-19
 ------------------
 
diff --git a/pixi.lock b/pixi.lock
index 09ccb6b9d..f6c6fabda 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -2416,6 +2416,102 @@ package:
   license_family: MIT
   size: 322086
   timestamp: 1695990976742
+- platform: linux-64
+  name: build
+  version: 0.7.0
+  category: main
+  manager: conda
+  dependencies:
+  - importlib-metadata
+  - packaging
+  - pep517 >=0.9.1
+  - python >=3.6
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/build-0.7.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: add7f31586d03678695b32b78a1337a1
+    sha256: 44e2d3270209d1f10b8adec2a159699ed66914e851ec34775902e856ea04afeb
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 17759
+  timestamp: 1631843776429
+- platform: osx-64
+  name: build
+  version: 0.7.0
+  category: main
+  manager: conda
+  dependencies:
+  - importlib-metadata
+  - packaging
+  - pep517 >=0.9.1
+  - python >=3.6
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/build-0.7.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: add7f31586d03678695b32b78a1337a1
+    sha256: 44e2d3270209d1f10b8adec2a159699ed66914e851ec34775902e856ea04afeb
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 17759
+  timestamp: 1631843776429
+- platform: osx-arm64
+  name: build
+  version: 0.7.0
+  category: main
+  manager: conda
+  dependencies:
+  - importlib-metadata
+  - packaging
+  - pep517 >=0.9.1
+  - python >=3.6
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/build-0.7.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: add7f31586d03678695b32b78a1337a1
+    sha256: 44e2d3270209d1f10b8adec2a159699ed66914e851ec34775902e856ea04afeb
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 17759
+  timestamp: 1631843776429
+- platform: win-64
+  name: build
+  version: 0.7.0
+  category: main
+  manager: conda
+  dependencies:
+  - importlib-metadata
+  - packaging
+  - pep517 >=0.9.1
+  - python >=3.6
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/build-0.7.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: add7f31586d03678695b32b78a1337a1
+    sha256: 44e2d3270209d1f10b8adec2a159699ed66914e851ec34775902e856ea04afeb
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 17759
+  timestamp: 1631843776429
 - platform: linux-64
   name: bzip2
   version: 1.0.8
@@ -4950,6 +5046,86 @@ package:
   license: LicenseRef-Public-Domain-Dedictation AND BSD-3-Clause AND BSD-2-Clause AND LicenseRef-PSF-2.1.1
   size: 972760
   timestamp: 1695301035902
+- platform: linux-64
+  name: editables
+  version: '0.3'
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.5
+  url: https://conda.anaconda.org/conda-forge/noarch/editables-0.3-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: ce5a846b6282ad352022dac9510e8fd6
+    sha256: 6d46237b3feafc1617705c9cf243b1dc79968842eec46e8ded6bb72bd60f5a00
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 8431
+  timestamp: 1649618340031
+- platform: osx-64
+  name: editables
+  version: '0.3'
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.5
+  url: https://conda.anaconda.org/conda-forge/noarch/editables-0.3-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: ce5a846b6282ad352022dac9510e8fd6
+    sha256: 6d46237b3feafc1617705c9cf243b1dc79968842eec46e8ded6bb72bd60f5a00
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 8431
+  timestamp: 1649618340031
+- platform: osx-arm64
+  name: editables
+  version: '0.3'
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.5
+  url: https://conda.anaconda.org/conda-forge/noarch/editables-0.3-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: ce5a846b6282ad352022dac9510e8fd6
+    sha256: 6d46237b3feafc1617705c9cf243b1dc79968842eec46e8ded6bb72bd60f5a00
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 8431
+  timestamp: 1649618340031
+- platform: win-64
+  name: editables
+  version: '0.3'
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.5
+  url: https://conda.anaconda.org/conda-forge/noarch/editables-0.3-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: ce5a846b6282ad352022dac9510e8fd6
+    sha256: 6d46237b3feafc1617705c9cf243b1dc79968842eec46e8ded6bb72bd60f5a00
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 8431
+  timestamp: 1649618340031
 - platform: linux-64
   name: exceptiongroup
   version: 1.1.3
@@ -7215,6 +7391,114 @@ package:
   license_family: BSD
   size: 97658
   timestamp: 1649144191039
+- platform: linux-64
+  name: hatchling
+  version: 1.21.0
+  category: main
+  manager: conda
+  dependencies:
+  - editables >=0.3
+  - importlib-metadata
+  - packaging >=21.3
+  - pathspec >=0.10.1
+  - pluggy >=1.0.0
+  - python >=3.7
+  - tomli >=1.2.2
+  - trove-classifiers
+  url: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.21.0-pyhd8ed1ab_0.conda
+  hash:
+    md5: 3a7957d6f4c1bff9a055eba725a6550b
+    sha256: c445953db5ab087a4cd378c149db48ee4a886b0e6ce0ce469d779458ac251bc8
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 58703
+  timestamp: 1702967711898
+- platform: osx-64
+  name: hatchling
+  version: 1.21.0
+  category: main
+  manager: conda
+  dependencies:
+  - editables >=0.3
+  - importlib-metadata
+  - packaging >=21.3
+  - pathspec >=0.10.1
+  - pluggy >=1.0.0
+  - python >=3.7
+  - tomli >=1.2.2
+  - trove-classifiers
+  url: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.21.0-pyhd8ed1ab_0.conda
+  hash:
+    md5: 3a7957d6f4c1bff9a055eba725a6550b
+    sha256: c445953db5ab087a4cd378c149db48ee4a886b0e6ce0ce469d779458ac251bc8
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 58703
+  timestamp: 1702967711898
+- platform: osx-arm64
+  name: hatchling
+  version: 1.21.0
+  category: main
+  manager: conda
+  dependencies:
+  - editables >=0.3
+  - importlib-metadata
+  - packaging >=21.3
+  - pathspec >=0.10.1
+  - pluggy >=1.0.0
+  - python >=3.7
+  - tomli >=1.2.2
+  - trove-classifiers
+  url: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.21.0-pyhd8ed1ab_0.conda
+  hash:
+    md5: 3a7957d6f4c1bff9a055eba725a6550b
+    sha256: c445953db5ab087a4cd378c149db48ee4a886b0e6ce0ce469d779458ac251bc8
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 58703
+  timestamp: 1702967711898
+- platform: win-64
+  name: hatchling
+  version: 1.21.0
+  category: main
+  manager: conda
+  dependencies:
+  - editables >=0.3
+  - importlib-metadata
+  - packaging >=21.3
+  - pathspec >=0.10.1
+  - pluggy >=1.0.0
+  - python >=3.7
+  - tomli >=1.2.2
+  - trove-classifiers
+  url: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.21.0-pyhd8ed1ab_0.conda
+  hash:
+    md5: 3a7957d6f4c1bff9a055eba725a6550b
+    sha256: c445953db5ab087a4cd378c149db48ee4a886b0e6ce0ce469d779458ac251bc8
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 58703
+  timestamp: 1702967711898
 - platform: linux-64
   name: hdf4
   version: 4.2.15
@@ -18179,6 +18463,86 @@ package:
   noarch: python
   size: 20743
   timestamp: 1695667673391
+- platform: linux-64
+  name: pathspec
+  version: 0.12.1
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda
+  hash:
+    md5: 17064acba08d3686f1135b5ec1b32b12
+    sha256: 4e534e66bfe8b1e035d2169d0e5b185450546b17e36764272863e22e0370be4d
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: MPL-2.0
+  license_family: MOZILLA
+  noarch: python
+  size: 41173
+  timestamp: 1702250135032
+- platform: osx-64
+  name: pathspec
+  version: 0.12.1
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda
+  hash:
+    md5: 17064acba08d3686f1135b5ec1b32b12
+    sha256: 4e534e66bfe8b1e035d2169d0e5b185450546b17e36764272863e22e0370be4d
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: MPL-2.0
+  license_family: MOZILLA
+  noarch: python
+  size: 41173
+  timestamp: 1702250135032
+- platform: osx-arm64
+  name: pathspec
+  version: 0.12.1
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda
+  hash:
+    md5: 17064acba08d3686f1135b5ec1b32b12
+    sha256: 4e534e66bfe8b1e035d2169d0e5b185450546b17e36764272863e22e0370be4d
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: MPL-2.0
+  license_family: MOZILLA
+  noarch: python
+  size: 41173
+  timestamp: 1702250135032
+- platform: win-64
+  name: pathspec
+  version: 0.12.1
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda
+  hash:
+    md5: 17064acba08d3686f1135b5ec1b32b12
+    sha256: 4e534e66bfe8b1e035d2169d0e5b185450546b17e36764272863e22e0370be4d
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: MPL-2.0
+  license_family: MOZILLA
+  noarch: python
+  size: 41173
+  timestamp: 1702250135032
 - platform: linux-64
   name: pcre2
   version: '10.40'
@@ -18263,6 +18627,90 @@ package:
   license_family: BSD
   size: 2001630
   timestamp: 1665563527916
+- platform: linux-64
+  name: pep517
+  version: 0.13.0
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.8
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/pep517-0.13.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: d94aa03d99d8adc9898f783eba0d84d2
+    sha256: 6a6f2fa6bc9106b2edcccc142242dc3ab1f2f77a6debbd5b480f08482f052636
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 19044
+  timestamp: 1667916747996
+- platform: osx-64
+  name: pep517
+  version: 0.13.0
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.8
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/pep517-0.13.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: d94aa03d99d8adc9898f783eba0d84d2
+    sha256: 6a6f2fa6bc9106b2edcccc142242dc3ab1f2f77a6debbd5b480f08482f052636
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 19044
+  timestamp: 1667916747996
+- platform: osx-arm64
+  name: pep517
+  version: 0.13.0
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.8
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/pep517-0.13.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: d94aa03d99d8adc9898f783eba0d84d2
+    sha256: 6a6f2fa6bc9106b2edcccc142242dc3ab1f2f77a6debbd5b480f08482f052636
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 19044
+  timestamp: 1667916747996
+- platform: win-64
+  name: pep517
+  version: 0.13.0
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.8
+  - tomli
+  url: https://conda.anaconda.org/conda-forge/noarch/pep517-0.13.0-pyhd8ed1ab_0.tar.bz2
+  hash:
+    md5: d94aa03d99d8adc9898f783eba0d84d2
+    sha256: 6a6f2fa6bc9106b2edcccc142242dc3ab1f2f77a6debbd5b480f08482f052636
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: MIT
+  license_family: MIT
+  noarch: python
+  size: 19044
+  timestamp: 1667916747996
 - platform: linux-64
   name: pexpect
   version: 4.8.0
@@ -24882,6 +25330,86 @@ package:
   noarch: python
   size: 109927
   timestamp: 1701095799725
+- platform: linux-64
+  name: trove-classifiers
+  version: 2024.1.8
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.1.8-pyhd8ed1ab_0.conda
+  hash:
+    md5: b120d43603f9b021d252fb7754b35557
+    sha256: ce42c9fc7a07a1586467e4e637f4ddc9e1082f1daacb31c98dc5f4c9f56e4601
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: linux-64
+  build_number: 0
+  license: Apache-2.0
+  license_family: Apache
+  noarch: python
+  size: 18404
+  timestamp: 1704733002526
+- platform: osx-64
+  name: trove-classifiers
+  version: 2024.1.8
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.1.8-pyhd8ed1ab_0.conda
+  hash:
+    md5: b120d43603f9b021d252fb7754b35557
+    sha256: ce42c9fc7a07a1586467e4e637f4ddc9e1082f1daacb31c98dc5f4c9f56e4601
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: osx-64
+  build_number: 0
+  license: Apache-2.0
+  license_family: Apache
+  noarch: python
+  size: 18404
+  timestamp: 1704733002526
+- platform: osx-arm64
+  name: trove-classifiers
+  version: 2024.1.8
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.1.8-pyhd8ed1ab_0.conda
+  hash:
+    md5: b120d43603f9b021d252fb7754b35557
+    sha256: ce42c9fc7a07a1586467e4e637f4ddc9e1082f1daacb31c98dc5f4c9f56e4601
+  build: pyhd8ed1ab_0
+  arch: aarch64
+  subdir: osx-arm64
+  build_number: 0
+  license: Apache-2.0
+  license_family: Apache
+  noarch: python
+  size: 18404
+  timestamp: 1704733002526
+- platform: win-64
+  name: trove-classifiers
+  version: 2024.1.8
+  category: main
+  manager: conda
+  dependencies:
+  - python >=3.7
+  url: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.1.8-pyhd8ed1ab_0.conda
+  hash:
+    md5: b120d43603f9b021d252fb7754b35557
+    sha256: ce42c9fc7a07a1586467e4e637f4ddc9e1082f1daacb31c98dc5f4c9f56e4601
+  build: pyhd8ed1ab_0
+  arch: x86_64
+  subdir: win-64
+  build_number: 0
+  license: Apache-2.0
+  license_family: Apache
+  noarch: python
+  size: 18404
+  timestamp: 1704733002526
 - platform: linux-64
   name: twine
   version: 4.0.2
diff --git a/pixi.toml b/pixi.toml
index db12cf37e..0d34ccd9c 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -1,6 +1,6 @@
 [project]
 name = "xugrid"
-version = "0.8.0"
+version = "0.8.1"
 description = "Xarray extension for unstructured grids"
 authors = ["Huite Bootsma <huite.bootsma@deltares.nl>"]
 channels = ["conda-forge"]
@@ -45,3 +45,5 @@ xarray = "*"
 zarr = "*"
 ipykernel = "6.26.0.*"  # So we can run examples
 twine = "*"
+hatchling = "1.21.0.*"
+build = "0.7.0.*"
diff --git a/pyproject.toml b/pyproject.toml
index 3fd259661..069926c65 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,11 @@
 [build-system]
-requires = ["setuptools>=64.0.0", "wheel"]
-build-backend = "setuptools.build_meta"
+requires = ["hatchling"]
+build-backend = "hatchling.build"
 
 [project]
 name = "xugrid"
 description = "Xarray extension for unstructured grids"
 readme = { file = "README.rst", content-type = "text/x-rst" }
-version = "0.8.0"
 maintainers = [{ name = "Huite Bootsma", email = "huite.bootsma@deltares.nl" }]
 requires-python = ">=3.9"
 dependencies = [
@@ -18,6 +17,7 @@ dependencies = [
     'scipy',
     'xarray',
 ]
+dynamic = ["version"]
 classifiers = [
     'Development Status :: 3 - Alpha',
     'Intended Audience :: Science/Research',
@@ -51,32 +51,12 @@ all = [
     'pyproj',
     'zarr',
 ]
-# pyproject.toml doesn't quite support re-use like setup.cfg did:
-# https://github.com/pypa/setuptools/issues/3221
-docs = [
-    'geopandas',
-    'matplotlib',
-    'meshkernel',
-    'netcdf4',
-    'pooch',
-    'pyproj',
-    'shapely >= 2.0',
-    'zarr',
-    'pydata-sphinx-theme',
-    'sphinx',
-    'sphinx-gallery',
-]
 
-[tool.setuptools]
-packages = [
-    "xugrid",
-    "xugrid.core",
-    "xugrid.data",
-    "xugrid.plot",
-    "xugrid.regrid",
-    "xugrid.ugrid",
-]
-license-files = ["LICENSE"]
+[tool.hatch.version]
+path = "xugrid/__init__.py"
+
+[tool.hatch.build.targets.sdist]
+only-include = ["xugrid", "tests"]
 
 [tool.isort]
 profile = "black"
diff --git a/xugrid/__init__.py b/xugrid/__init__.py
index a351e265b..88bb7a730 100644
--- a/xugrid/__init__.py
+++ b/xugrid/__init__.py
@@ -1,5 +1,3 @@
-from importlib.metadata import version as _version
-
 from xugrid import data
 from xugrid.core.common import (
     concat,
@@ -30,11 +28,7 @@
 from xugrid.ugrid.ugrid1d import Ugrid1d
 from xugrid.ugrid.ugrid2d import Ugrid2d
 
-try:
-    __version__ = _version("xugrid")
-except Exception:
-    # Disable minimum version checks on downstream libraries.
-    __version__ = "9999"
+__version__ = "0.8.1"
 
 __all__ = (
     "data",
diff --git a/xugrid/conversion.py b/xugrid/conversion.py
index 629fc7ec8..20a955e9a 100644
--- a/xugrid/conversion.py
+++ b/xugrid/conversion.py
@@ -133,48 +133,110 @@ def _array_spacing(coords, spacing):
     return halfdiff
 
 
-def _implicit_spacing(coords):
-    dim = coords.dims[0]
-    if coords.size == 1:
-        raise ValueError(
-            f"Cannot derive spacing of 1-sized coordinate: {coords.name} \n"
-            f"Set bounds yourself or assign a d{coords.name} variable with spacing"
-        )
-    halfdiff = 0.5 * abs(coords.diff(dim)).to_numpy()
-    return np.insert(halfdiff, 0, halfdiff[0])
+def _is_monotonic_and_increasing(coord, axis=0) -> bool:
+    """
+    Test if monotonic and retun whether increasing along axis.
+    Raises error if not monotonic.
+
+    Copied and slightly adapted from xarray.utils.
+
+    >>> _is_monotonic(np.array([0, 1, 2]))
+    True
+    >>> _is_monotonic(np.array([2, 1, 0]))
+    True
+    >>> _is_monotonic(np.array([0, 2, 1]))
+    False
+    """
+    coord = np.asarray(coord)
+    n = coord.shape[axis]
+    delta_pos = coord.take(np.arange(1, n), axis=axis) >= coord.take(
+        np.arange(0, n - 1), axis=axis
+    )
+    delta_neg = coord.take(np.arange(1, n), axis=axis) <= coord.take(
+        np.arange(0, n - 1), axis=axis
+    )
+    if np.all(delta_pos):
+        return True
+    elif np.all(delta_neg):
+        return False
+    else:
+        raise ValueError("The input coordinate is not monotonic.")
+
+
+def infer_interval_breaks(coord, axis: int = 0, check_monotonic: bool = False):
+    """
+    Infer intervals from cell center coordinates.
+
+    Copied and adapted from xarray.utils.
+
+    >>> _infer_interval_breaks(np.arange(5))
+    array([-0.5,  0.5,  1.5,  2.5,  3.5,  4.5])
+    >>> _infer_interval_breaks([[0, 1], [3, 4]], axis=1)
+    array([[-0.5,  0.5,  1.5],
+           [ 2.5,  3.5,  4.5]])
+    """
+    coord = np.asarray(coord)
+    if check_monotonic:
+        _is_monotonic_and_increasing(coord, axis=axis)
+
+    deltas = 0.5 * np.diff(coord, axis=axis)
+    if deltas.size == 0:
+        deltas = np.array(0.0)
+
+    first = np.take(coord, [0], axis=axis) - np.take(deltas, [0], axis=axis)
+    last = np.take(coord, [-1], axis=axis) + np.take(deltas, [-1], axis=axis)
+    trim_last = tuple(
+        slice(None, -1) if n == axis else slice(None) for n in range(coord.ndim)
+    )
+    interval_breaks = np.concatenate(
+        [first, coord[trim_last] + deltas, last], axis=axis
+    )
+    return interval_breaks
 
 
-def infer_bounds(
+def infer_interval_breaks1d(
     obj: Union[xr.DataArray, xr.Dataset],
     var: str,
-):
-    coords = obj[var]
-    index = obj.indexes[var]
-    if not (index.is_monotonic_increasing or index.is_monotonic_decreasing):
-        raise ValueError(f"{var} is not monotonic")
+) -> np.ndarray:
+    """
+    Infer the breaks for 1D coordinates.
 
-    # e.g. rioxarray will set dx, dy as (scalar) values.
+        * For non-equidistant grids, taking half of each cell size result in
+          wrong answers.
+        * Cell size may be provided by a dx or dy attribute instead.
+        * We also want to take 1-row or 1 column topologies into account.
+    """
+    coord = obj[var]
     spacing_name = f"d{var}"
+
+    # Spacing name is provided:
     if spacing_name in obj.coords:
         spacing = obj[spacing_name]
-        spacing_shape = spacing.shape
-        if len(spacing_shape) > 1:
+        if spacing.ndim > 1:
             raise NotImplementedError(
                 f"More than one dimension in spacing variable: {spacing_name}"
             )
+        if spacing.shape in ((), (1,)):
+            halfdiff = _scalar_spacing(coord, spacing)
+        else:
+            halfdiff = _array_spacing(coord, spacing)
 
-        if spacing_shape in ((), (1,)):
-            halfdiff = _scalar_spacing(coords, spacing)
+        # Now check if monotonic and take orientation into account.
+        if _is_monotonic_and_increasing(coord):
+            intervals = np.insert(coord + halfdiff, 0, coord[0] - halfdiff[0])
         else:
-            halfdiff = _array_spacing(coords, spacing)
-    # Implicit spacing
+            intervals = np.insert(coord - halfdiff, 0, coord[0] + halfdiff[0])
+
+    # Implicit spacing, infer from coordinates instead:
     else:
-        halfdiff = _implicit_spacing(coords)
+        if coord.size == 1:
+            raise ValueError(
+                f"Cannot derive spacing of 1-sized coordinate: {var} \n"
+                f"Assign a d{var} variable with spacing instead."
+            )
+        intervals = infer_interval_breaks(coord.to_numpy(), check_monotonic=True)
 
-    lower = coords - halfdiff
-    upper = coords + halfdiff
-    bounds = xr.concat([lower, upper], dim="bounds").transpose()
-    return bounds
+    return intervals
 
 
 def infer_xy_coords(obj):
diff --git a/xugrid/core/wrap.py b/xugrid/core/wrap.py
index 2ca80b74d..88d00b9cb 100644
--- a/xugrid/core/wrap.py
+++ b/xugrid/core/wrap.py
@@ -3,6 +3,7 @@
 
 This allows for tab completion and documentation.
 """
+from __future__ import annotations
 
 import types
 from collections import ChainMap
@@ -230,16 +231,28 @@ def ugrid(self):
         )
 
     @staticmethod
-    def from_structured(da: xr.DataArray):
+    def from_structured(
+        da: xr.DataArray,
+        x: str | None = None,
+        y: str | None = None,
+    ) -> "UgridDataArray":
         """
         Create a UgridDataArray from a (structured) xarray DataArray.
 
         The spatial dimensions are flattened into a single UGRID face dimension.
 
+        By default, this method looks for the "x" and "y" coordinates and assumes
+        they are one-dimensional. To convert rotated or curvilinear coordinates,
+        provide the names of the x and y coordinates.
+
         Parameters
         ----------
         da: xr.DataArray
             Last two dimensions must be ``("y", "x")``.
+        x: str, default: None
+            Which coordinate to use as the UGRID x-coordinate.
+        y: str, default: None
+            Which coordinate to use as the UGRID y-coordinate.
 
         Returns
         -------
@@ -247,7 +260,20 @@ def from_structured(da: xr.DataArray):
         """
         if da.dims[-2:] != ("y", "x"):
             raise ValueError('Last two dimensions of da must be ("y", "x")')
-        grid = Ugrid2d.from_structured(da)
+        if (x is None) ^ (y is None):
+            raise ValueError("Provide both x and y, or neither.")
+        if x is None:
+            grid = Ugrid2d.from_structured(da)
+        else:
+            # Find out if it's multi-dimensional
+            xdim = da[x].ndim
+            if xdim == 1:
+                grid = Ugrid2d.from_structured(da, x=x, y=y)
+            elif xdim == 2:
+                grid = Ugrid2d.from_structured_multicoord(da, x=x, y=y)
+            else:
+                raise ValueError(f"x and y must be 1D or 2D. Found: {xdim}")
+
         dims = da.dims[:-2]
         coords = {k: da.coords[k] for k in dims}
         face_da = xr.DataArray(
diff --git a/xugrid/ugrid/ugrid2d.py b/xugrid/ugrid/ugrid2d.py
index ad4e8154e..fd744ece5 100644
--- a/xugrid/ugrid/ugrid2d.py
+++ b/xugrid/ugrid/ugrid2d.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from itertools import chain
 from typing import Any, Dict, Optional, Sequence, Tuple, Union
 
@@ -1970,21 +1972,15 @@ def from_shapely(geometry: PolygonArray, crs=None) -> "Ugrid2d":
         return Ugrid2d(x, y, fill_value, face_node_connectivity, crs=crs)
 
     @staticmethod
-    def from_structured_bounds(
-        x_bounds: np.ndarray,
-        y_bounds: np.ndarray,
+    def _from_intervals_helper(
+        node_x: np.ndarray,
+        node_y: np.ndarray,
+        nx: int,
+        ny: int,
     ) -> "Ugrid2d":
-        nx, _ = x_bounds.shape
-        ny, _ = y_bounds.shape
-        nfaces = ny * nx
-        x = conversion.bounds_to_vertices(x_bounds)
-        y = conversion.bounds_to_vertices(y_bounds)
-
-        # Compute all vertices, these are the ugrid nodes
-        node_y, node_x = (a.ravel() for a in np.meshgrid(y, x, indexing="ij"))
         linear_index = np.arange(node_x.size, dtype=IntDType).reshape((ny + 1, nx + 1))
         # Allocate face_node_connectivity
-        face_nodes = np.empty((nfaces, 4), dtype=IntDType)
+        face_nodes = np.empty((ny * nx, 4), dtype=IntDType)
         # Set connectivity in counterclockwise manner
         face_nodes[:, 0] = linear_index[:-1, 1:].ravel()  # upper right
         face_nodes[:, 1] = linear_index[:-1, :-1].ravel()  # upper left
@@ -1992,28 +1988,158 @@ def from_structured_bounds(
         face_nodes[:, 3] = linear_index[1:, 1:].ravel()  # lower right
         return Ugrid2d(node_x, node_y, -1, face_nodes)
 
+    @staticmethod
+    def from_structured_intervals1d(
+        x_intervals: np.ndarray,
+        y_intervals: np.ndarray,
+    ) -> "Ugrid2d":
+        """
+        Create a Ugrid2d topology from a structured topology based on 1D intervals.
+
+        Parameters
+        ----------
+        x_intervals: np.ndarray of shape (M + 1,)
+            x-coordinate interval values for N row and M columns.
+        y_intervals: np.ndarray of shape (N + 1,)
+            y-coordinate interval values for N row and M columns.
+        """
+        x_intervals = np.asarray(x_intervals)
+        y_intervals = np.asarray(y_intervals)
+        nx = x_intervals.shape[0] - 1
+        ny = y_intervals.shape[0] - 1
+        node_y, node_x = (
+            a.ravel() for a in np.meshgrid(y_intervals, x_intervals, indexing="ij")
+        )
+        return Ugrid2d._from_intervals_helper(node_x, node_y, nx, ny)
+
+    @staticmethod
+    def from_structured_intervals2d(
+        x_intervals: np.ndarray,
+        y_intervals: np.ndarray,
+    ) -> "Ugrid2d":
+        """
+        Create a Ugrid2d topology from a structured topology based on 2D intervals.
+
+        Parameters
+        ----------
+        x_intervals: np.ndarray of shape shape (N + 1, M + 1)
+            x-coordinate interval values for N row and M columns.
+        y_intervals: np.ndarray of shape shape (N + 1, M + 1)
+            y-coordinate interval values for N row and M columns.
+        """
+        x_intervals = np.asarray(x_intervals)
+        y_intervals = np.asarray(y_intervals)
+        shape = x_intervals.shape
+        if (x_intervals.ndim != 2) or (y_intervals.ndim != 2):
+            raise ValueError("Dimensions of intervals must be 2D.")
+        if shape != y_intervals.shape:
+            raise ValueError(
+                "Interval shapes must match. Found: "
+                f"x_intervals: {shape}, versus y_intervals: {y_intervals.shape}"
+            )
+        nx = shape[1] - 1
+        ny = shape[0] - 1
+        node_x = x_intervals.ravel()
+        node_y = y_intervals.ravel()
+        return Ugrid2d._from_intervals_helper(node_x, node_y, nx, ny)
+
+    @staticmethod
+    def from_structured_bounds(
+        x_bounds: np.ndarray,
+        y_bounds: np.ndarray,
+    ) -> "Ugrid2d":
+        """
+        Create a Ugrid2d topology from a structured topology based on 1D bounds.
+
+        The bounds contain the lower and upper cell boundary for each cell.
+
+        Parameters
+        ----------
+        x_bounds: np.ndarray of shape (M, 2)
+            x-coordinate bounds for N row and M columns.
+        y_bounds: np.ndarray of shape (N, 2)
+            y-coordinate bounds for N row and M columns.
+
+        Returns
+        -------
+        grid: Ugrid2d
+        """
+        nx, _ = x_bounds.shape
+        ny, _ = y_bounds.shape
+        x = conversion.bounds_to_vertices(x_bounds)
+        y = conversion.bounds_to_vertices(y_bounds)
+        node_y, node_x = (a.ravel() for a in np.meshgrid(y, x, indexing="ij"))
+        return Ugrid2d._from_intervals_helper(node_x, node_y, nx, ny)
+
     @staticmethod
     def from_structured(
         data: Union[xr.DataArray, xr.Dataset],
-        x_bounds: str = None,
-        y_bounds: str = None,
+        x: str | None = None,
+        y: str | None = None,
     ) -> "Ugrid2d":
-        if x_bounds is not None and y_bounds is not None:
-            x_bounds = data[x_bounds]
-            y_bounds = data[y_bounds]
-        else:
-            x_coord, y_coord = conversion.infer_xy_coords(data)
-            x_bounds = conversion.infer_bounds(data, x_coord)
-            y_bounds = conversion.infer_bounds(data, y_coord)
-            if x_bounds is None or y_bounds is None:
+        """
+        Create a Ugrid2d topology from an axis-aligned rectilinear structured topology.
+
+        This method assumes the coordinates are 1D.
+
+        Use ``from_structured_multicoord`` for 2D x and y coordinates, e.g. for
+        (approximated) curvilinear and rotated structured topologies.
+
+        Parameters
+        ----------
+        data: xr.DataArray or xr.Dataset
+        x: str, optional
+            Name of the 1D coordinate to use as the UGRID x-coordinate.
+        y: str, optional
+            Name of the 1D coordinate to use as the UGRID y-coordinate.
+
+        Returns
+        -------
+        grid: Ugrid2d
+        """
+        if x is None or y is None:
+            x, y = conversion.infer_xy_coords(data)
+            if x is None or y is None:
                 raise ValueError(
-                    "Could not infer bounds. Please provide x_bounds and"
-                    " y_bounds explicitly."
+                    "Could not infer bounds. Please provide x and y explicitly."
                 )
-        return Ugrid2d.from_structured_bounds(
-            x_bounds.to_numpy(),
-            y_bounds.to_numpy(),
-        )
+
+        x_intervals = conversion.infer_interval_breaks1d(data, x)
+        y_intervals = conversion.infer_interval_breaks1d(data, y)
+        return Ugrid2d.from_structured_intervals1d(x_intervals, y_intervals)
+
+    @staticmethod
+    def from_structured_multicoord(
+        data: Union[xr.DataArray, xr.Dataset],
+        x: str,
+        y: str,
+    ) -> "Ugrid2d":
+        """
+        Create a Ugrid2d topology from a structured topology, including rotated
+        and (approximated) curvilinear topologies.
+
+        This method assumes the coordinates are 2D.
+
+        Use ``from_structured`` for 1D x and y coordinates, which is generally
+        the case for axis-aligned rectilinear topologies (most rasters).
+
+        Parameters
+        ----------
+        data: xr.DataArray or xr.Dataset
+        x: str
+            Name of the 2D coordinate to use as the UGRID x-coordinate.
+        y: str
+            Name of the 2D coordinate to use as the UGRID y-coordinate.
+
+        Returns
+        -------
+        grid: Ugrid2d
+        """
+        xv = conversion.infer_interval_breaks(data[x], axis=1, check_monotonic=True)
+        xv = conversion.infer_interval_breaks(xv, axis=0)
+        yv = conversion.infer_interval_breaks(data[y], axis=1)
+        yv = conversion.infer_interval_breaks(yv, axis=0, check_monotonic=True)
+        return Ugrid2d.from_structured_intervals2d(xv, yv)
 
     def to_shapely(self, dim):
         """


Test patch:
diff --git a/tests/test_conversion.py b/tests/test_conversion.py
index 91284b18f..12936314f 100644
--- a/tests/test_conversion.py
+++ b/tests/test_conversion.py
@@ -130,6 +130,56 @@ def test_faces_geos_roundtrip__mixed(mixed_mesh):
     _faces_geos_roundtrip(mixed_mesh)
 
 
+def test_is_monotonic_and_increasing():
+    with pytest.raises(ValueError):
+        cv._is_monotonic_and_increasing([0.0, -1.0, 2.0])
+    with pytest.raises(ValueError):
+        cv._is_monotonic_and_increasing([2.0, 0.0, 1.0])
+
+    assert cv._is_monotonic_and_increasing([0.0, 1.0, 2.0])
+    assert not cv._is_monotonic_and_increasing([2.0, 1.0, 0.0])
+
+    ascending = np.array(
+        [
+            [0.0, 1.0, 2.0],
+            [3.0, 4.0, 5.0],
+            [6.0, 7.0, 8.0],
+        ]
+    )
+    descending = np.array(
+        [
+            [8.0, 7.0, 6.0],
+            [5.0, 4.0, 3.0],
+            [2.0, 1.0, 0.0],
+        ]
+    )
+    assert cv._is_monotonic_and_increasing(ascending, axis=0)
+    assert cv._is_monotonic_and_increasing(ascending, axis=1)
+    assert not cv._is_monotonic_and_increasing(descending, axis=1)
+    assert not cv._is_monotonic_and_increasing(descending, axis=1)
+
+
+def test_infer_interval_breaks():
+    assert np.allclose([-0.5, 0.5, 1.5], cv.infer_interval_breaks([0, 1]))
+    assert np.allclose(
+        [-0.5, 0.5, 5.0, 9.5, 10.5], cv.infer_interval_breaks([0, 1, 9, 10])
+    )
+
+    xref, yref = np.meshgrid(np.arange(6), np.arange(5))
+    cx = (xref[1:, 1:] + xref[:-1, :-1]) / 2
+    cy = (yref[1:, 1:] + yref[:-1, :-1]) / 2
+    x = cv.infer_interval_breaks(cx, axis=1)
+    x = cv.infer_interval_breaks(x, axis=0)
+    y = cv.infer_interval_breaks(cy, axis=1)
+    y = cv.infer_interval_breaks(y, axis=0)
+    np.testing.assert_allclose(xref, x)
+    np.testing.assert_allclose(yref, y)
+
+    # test that ValueError is raised for non-monotonic 1D inputs
+    with pytest.raises(ValueError):
+        cv.infer_interval_breaks(np.array([0, 2, 1]), check_monotonic=True)
+
+
 def test_scalar_spacing(structured_mesh_ascending, structured_mesh_descending):
     upcoords = structured_mesh_ascending.coords["x"]
     downcoords = structured_mesh_descending.coords["x"]
@@ -163,34 +213,25 @@ def test_implicit_spacing(structured_mesh_ascending, structured_mesh_descending)
         ["y", "x"],
     )
     with pytest.raises(ValueError, match="Cannot derive spacing of 1-sized coordinate"):
-        cv._implicit_spacing(da.coords["y"])
+        cv.infer_interval_breaks1d(da, "y")
 
-    upcoords = structured_mesh_ascending.coords["x"]
-    downcoords = structured_mesh_descending.coords["x"]
-    assert np.allclose(cv._implicit_spacing(upcoords), 1.0)
-    assert np.allclose(cv._implicit_spacing(downcoords), 1.0)
+    actual = cv.infer_interval_breaks1d(structured_mesh_ascending, "x")
+    assert np.allclose(actual, [1.0, 3.0, 5.0, 7.0, 9.0])
+    actual = cv.infer_interval_breaks1d(structured_mesh_descending, "x")
+    assert np.allclose(actual, [9.0, 7.0, 5.0, 3.0, 1.0])
 
 
 @pytest.mark.parametrize("spacing_type", ["implicit", "scalar", "array"])
-def test_infer_bounds(
+def test_infer_breaks_intervals1d(
     structured_mesh_ascending, structured_mesh_descending, spacing_type
 ):
     up = structured_mesh_ascending
     down = structured_mesh_descending
     x_expected = np.array(
-        [
-            [1.0, 3.0],
-            [3.0, 5.0],
-            [5.0, 7.0],
-            [7.0, 9.0],
-        ]
+        [1.0, 3.0, 5.0, 7.0, 9.0],
     )
     y_expected = np.array(
-        [
-            [2.5, 7.5],
-            [7.5, 12.5],
-            [12.5, 17.5],
-        ]
+        [2.5, 7.5, 12.5, 17.5],
     )
 
     if spacing_type == "scalar":
@@ -200,21 +241,21 @@ def test_infer_bounds(
         up = up.assign_coords({"dx": ("x", [2.0] * 4), "dy": ("y", [5.0] * 3)})
         down = down.assign_coords({"dx": ("x", [2.0] * 4), "dy": ("y", [5.0] * 3)})
 
-    assert np.allclose(cv.infer_bounds(up, "x"), x_expected)
-    assert np.allclose(cv.infer_bounds(up, "y"), y_expected)
-    assert np.allclose(cv.infer_bounds(down, "x"), x_expected[::-1])
-    assert np.allclose(cv.infer_bounds(down, "y"), y_expected[::-1])
+    assert np.allclose(cv.infer_interval_breaks1d(up, "x"), x_expected)
+    assert np.allclose(cv.infer_interval_breaks1d(up, "y"), y_expected)
+    assert np.allclose(cv.infer_interval_breaks1d(down, "x"), x_expected[::-1])
+    assert np.allclose(cv.infer_interval_breaks1d(down, "y"), y_expected[::-1])
 
 
-def test_infer_bounds_errors(structured_mesh_ascending):
+def test_infer_breaks_intervals1d_errors(structured_mesh_ascending):
     up = structured_mesh_ascending
     up = up.assign_coords(x=[2.0, 4.0, 3.0, 8.0])
 
-    with pytest.raises(ValueError, match="x is not monotonic"):
-        cv.infer_bounds(up, "x")
+    with pytest.raises(ValueError, match="The input coordinate is not monotonic."):
+        cv.infer_interval_breaks1d(up, "x")
 
 
-def test_bounds_to_vertices(structured_mesh_ascending, structured_mesh_descending):
+def test_bounds_to_vertices():
     with pytest.raises(ValueError, match="Bounds are not monotonic"):
         cv.bounds_to_vertices(
             xr.DataArray(
@@ -222,20 +263,20 @@ def test_bounds_to_vertices(structured_mesh_ascending, structured_mesh_descendin
             )
         )
 
-    up = structured_mesh_ascending
-    down = structured_mesh_descending
     x_vertices = np.array([1.0, 3.0, 5.0, 7.0, 9.0])
     y_vertices = np.array([2.5, 7.5, 12.5, 17.5])
     # Ascending
-    x_bounds = cv.infer_bounds(up, "x")
-    y_bounds = cv.infer_bounds(up, "y")
+    x_bounds = np.column_stack((x_vertices[:-1], x_vertices[1:]))
+    y_bounds = np.column_stack((y_vertices[:-1], y_vertices[1:]))
     assert np.allclose(cv.bounds_to_vertices(x_bounds), x_vertices)
     assert np.allclose(cv.bounds_to_vertices(y_bounds), y_vertices)
     # Descending
-    x_bounds = cv.infer_bounds(down, "x")
-    y_bounds = cv.infer_bounds(down, "y")
-    assert np.allclose(cv.bounds_to_vertices(x_bounds), x_vertices[::-1])
-    assert np.allclose(cv.bounds_to_vertices(y_bounds), y_vertices[::-1])
+    xrev = x_vertices[::-1]
+    yrev = y_vertices[::-1]
+    x_bounds = np.column_stack((xrev[1:], xrev[:-1]))
+    y_bounds = np.column_stack((yrev[1:], yrev[:-1]))
+    assert np.allclose(cv.bounds_to_vertices(x_bounds), xrev)
+    assert np.allclose(cv.bounds_to_vertices(y_bounds), yrev)
 
 
 def test_infer_xy_coords():
diff --git a/tests/test_ugrid2d.py b/tests/test_ugrid2d.py
index 88ff520aa..fcf2453d5 100644
--- a/tests/test_ugrid2d.py
+++ b/tests/test_ugrid2d.py
@@ -979,6 +979,84 @@ def test_meshkernel():
     assert isinstance(grid.meshkernel, mk.MeshKernel)
 
 
+def test_from_structured_intervals1d():
+    grid = xugrid.Ugrid2d.from_structured_intervals1d(
+        x_intervals=[0.0, 1.0, 2.0],
+        y_intervals=[2.0, 1.0, 0.0],
+    )
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 4
+
+
+def test_from_structured_intervals2d():
+    with pytest.raises(ValueError, match="Dimensions of intervals must be 2D."):
+        xugrid.Ugrid2d.from_structured_intervals2d(
+            x_intervals=[0.0, 1.0, 2.0],
+            y_intervals=[2.0, 1.0, 0.0],
+        )
+    with pytest.raises(ValueError, match="Interval shapes must match."):
+        xugrid.Ugrid2d.from_structured_intervals2d(
+            x_intervals=[[0.0, 1.0, 2.0]],
+            y_intervals=[[2.0, 1.0, 0.0, 4.0]],
+        )
+
+    grid = xugrid.Ugrid2d.from_structured_intervals2d(
+        x_intervals=[[0.0, 1.0, 2.0], [0.0, 1.0, 2.0], [0.0, 1.0, 2.0]],
+        y_intervals=[
+            [2.0, 1.0, 0.0],
+            [2.0, 1.0, 0.0],
+            [2.0, 1.0, 0.0],
+        ],
+    )
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 4
+
+
+def test_from_structured_bounds():
+    x_vertices = np.array([1.0, 3.0, 5.0, 7.0, 9.0])
+    y_vertices = np.array([2.5, 7.5, 12.5, 17.5])
+    # Ascending
+    x_bounds = np.column_stack((x_vertices[:-1], x_vertices[1:]))
+    y_bounds = np.column_stack((y_vertices[:-1], y_vertices[1:]))
+    grid = xugrid.Ugrid2d.from_structured_bounds(x_bounds, y_bounds)
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 12
+
+
+def test_from_structured():
+    da = xr.DataArray(
+        data=np.ones((2, 2)),
+        coords={"y": [12.0, 11.0], "x": [1.0, 2.0]},
+        dims=("y", "x"),
+    )
+    grid = xugrid.Ugrid2d.from_structured(da)
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 4
+
+    da = xr.DataArray(
+        data=np.ones((2, 2)),
+        coords={"lat": [12.0, 11.0], "lon": [1.0, 2.0]},
+        dims=("lat", "lon"),
+    )
+    grid = xugrid.Ugrid2d.from_structured(da, x="lon", y="lat")
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 4
+
+
+def test_form_structured_multicoord():
+    da = xr.DataArray(
+        data=np.ones((2, 2)),
+        coords={
+            "yc": (("y", "x"), [[12.0, 11.0], [13.0, 12.0]]),
+            "xc": (("y", "x"), [[1.0, 2.0], [2.0, 3.0]]),
+        },
+        dims=("y", "x"),
+    )
+    grid = xugrid.Ugrid2d.from_structured_multicoord(da, x="xc", y="yc")
+    assert isinstance(grid, xugrid.Ugrid2d)
+    assert grid.n_face == 4
+
+
 def test_from_shapely():
     with pytest.raises(TypeError):
         x = np.array([0.0, 1.0, 2.0])
diff --git a/tests/test_ugrid_dataset.py b/tests/test_ugrid_dataset.py
index 0de3ee8b0..93900af60 100644
--- a/tests/test_ugrid_dataset.py
+++ b/tests/test_ugrid_dataset.py
@@ -177,6 +177,25 @@ def test_from_structured(self):
         uda = xugrid.UgridDataArray.from_structured(flipped)
         assert np.allclose(uda.ugrid.sel(x=2.0, y=5.0), [[0], [12]])
 
+    def test_from_structured_multicoord(self):
+        da = xr.DataArray(
+            data=[[0, 1], [2, 3]],
+            coords={
+                "yc": (("y", "x"), [[12.0, 11.0], [12.0, 11.0]]),
+                "xc": (("y", "x"), [[10.0, 12.0], [10.0, 12.0]]),
+            },
+            dims=("y", "x"),
+        )
+        uda = xugrid.UgridDataArray.from_structured(da)
+        assert isinstance(uda, xugrid.UgridDataArray)
+        assert np.array_equal(np.unique(uda.ugrid.grid.node_x), [-0.5, 0.5, 1.5])
+        assert np.array_equal(uda.data, [0, 1, 2, 3])
+
+        uda = xugrid.UgridDataArray.from_structured(da, x="xc", y="yc")
+        assert isinstance(uda, xugrid.UgridDataArray)
+        assert np.array_equal(np.unique(uda.ugrid.grid.node_x), [9.0, 11.0, 13.0])
+        assert np.array_equal(uda.data, [0, 1, 2, 3])
+
     def test_unary_op(self):
         alltrue = self.uda.astype(bool)
         allfalse = alltrue.copy()