diff --git a/figures.ipynb b/figures.ipynb
index 81f605af2470d3146df7d13d0c55d3414c146967..a296429f830293cbfcd3653e34f92ebe7d5ce0c3 100644
--- a/figures.ipynb
+++ b/figures.ipynb
@@ -652,6 +652,216 @@
     "for d in funcs_1D:\n",
     "    print(d[\"err_hom\"][-1] / d[\"err_adaptive\"][-1])"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Iso surface"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from functools import lru_cache\n",
+    "import numpy as np\n",
+    "import scipy.linalg\n",
+    "import scipy.spatial\n",
+    "import kwant\n",
+    "\n",
+    "\n",
+    "@lru_cache()\n",
+    "def create_syst(unit_cell):\n",
+    "    lat = kwant.lattice.Polyatomic(unit_cell, [(0, 0, 0)])\n",
+    "    syst = kwant.Builder(kwant.TranslationalSymmetry(*lat.prim_vecs))\n",
+    "    syst[lat.shape(lambda _: True, (0, 0, 0))] = 6\n",
+    "    syst[lat.neighbors()] = -1\n",
+    "    return kwant.wraparound.wraparound(syst).finalized()\n",
+    "\n",
+    "\n",
+    "def get_brillouin_zone(unit_cell):\n",
+    "    syst = create_syst(unit_cell)\n",
+    "    A = get_A(syst)\n",
+    "    neighbours = kwant.linalg.lll.voronoi(A)\n",
+    "    lattice_points = np.concatenate(([[0, 0, 0]], neighbours))\n",
+    "    lattice_points = 2 * np.pi * (lattice_points @ A.T)\n",
+    "    vor = scipy.spatial.Voronoi(lattice_points)\n",
+    "    brillouin_zone = vor.vertices[vor.regions[vor.point_region[0]]]\n",
+    "    return scipy.spatial.ConvexHull(brillouin_zone)\n",
+    "\n",
+    "\n",
+    "def momentum_to_lattice(k, syst):\n",
+    "    A = get_A(syst)\n",
+    "    k, residuals = scipy.linalg.lstsq(A, k)[:2]\n",
+    "    if np.any(abs(residuals) > 1e-7):\n",
+    "        raise RuntimeError(\n",
+    "            \"Requested momentum doesn't correspond to any lattice momentum.\"\n",
+    "        )\n",
+    "    return k\n",
+    "\n",
+    "\n",
+    "def get_A(syst):\n",
+    "    B = np.asarray(syst._wrapped_symmetry.periods).T\n",
+    "    return np.linalg.pinv(B).T\n",
+    "\n",
+    "\n",
+    "def energies(k, unit_cell):\n",
+    "    syst = create_syst(unit_cell)\n",
+    "    k_x, k_y, k_z = momentum_to_lattice(k, syst)\n",
+    "    params = {\"k_x\": k_x, \"k_y\": k_y, \"k_z\": k_z}\n",
+    "    H = syst.hamiltonian_submatrix(params=params)\n",
+    "    energies = np.linalg.eigvalsh(H)\n",
+    "    return min(energies)\n",
+    "\n",
+    "\n",
+    "from functools import partial\n",
+    "\n",
+    "from ipywidgets import interact_manual\n",
+    "\n",
+    "\n",
+    "# Define the lattice vectors of some common unit cells\n",
+    "lattices = dict(\n",
+    "    hexegonal=((0, 1, 0), (np.cos(-np.pi / 6), np.sin(-np.pi / 6), 0), (0, 0, 1)),\n",
+    "    simple_cubic=((1, 0, 0), (0, 1, 0), (0, 0, 1)),\n",
+    "    fcc=((0, 0.5, 0.5), (0.5, 0.5, 0), (0.5, 0, 0.5)),\n",
+    "    bcc=((-0.5, 0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, -0.5)),\n",
+    ")\n",
+    "\n",
+    "\n",
+    "def isoline_loss_function(y_iso, sigma, priority=1):\n",
+    "    from adaptive.learner.learnerND import default_loss\n",
+    "\n",
+    "    def gaussian(x, mu, sigma):\n",
+    "        return np.exp(-(x - mu) ** 2 / sigma ** 2)\n",
+    "\n",
+    "    def loss(simplex, ys):\n",
+    "        distance = np.mean([abs(y_iso - y) for y in ys])\n",
+    "        return priority * gaussian(distance, 0, sigma) + default_loss(simplex, ys)\n",
+    "\n",
+    "    return loss\n",
+    "\n",
+    "\n",
+    "loss = isoline_loss_function(y_iso=0.1, sigma=1, priority=0.0)\n",
+    "\n",
+    "learners = []\n",
+    "fnames = []\n",
+    "for name, unit_cell in lattices.items():\n",
+    "    hull = get_brillouin_zone(unit_cell)\n",
+    "    learner = adaptive.LearnerND(\n",
+    "        partial(energies, unit_cell=unit_cell), hull, loss_per_simplex=loss\n",
+    "    )\n",
+    "    fnames.append(name)\n",
+    "    learners.append(learner)\n",
+    "\n",
+    "mapping = dict(zip(fnames, learners))\n",
+    "\n",
+    "learner = adaptive.BalancingLearner(learners, strategy=\"npoints\")\n",
+    "\n",
+    "\n",
+    "def select(name, learners, fnames):\n",
+    "    return learners[fnames.index(name)]\n",
+    "\n",
+    "\n",
+    "def iso(unit_cell, level=8.5):\n",
+    "    l = select(unit_cell, learners, fnames)\n",
+    "    adaptive.runner.simple(l, goal=lambda l: l.npoints > 1000)\n",
+    "    return l.plot_isosurface(level=level)\n",
+    "\n",
+    "interact_manual(iso, level=(-6, 9, 0.1), unit_cell=lattices.keys())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def f(xy):\n",
+    "    x, y = xy\n",
+    "    return x ** 2 + y ** 3\n",
+    "\n",
+    "bounds = [(-1, 1), (-1, 1)]\n",
+    "npoints = 300\n",
+    "\n",
+    "def isoline_loss_function(y_iso, sigma, priority=1):\n",
+    "    from adaptive.learner.learnerND import default_loss\n",
+    "\n",
+    "    def gaussian(x, mu, sigma):\n",
+    "        return np.exp(-(x - mu) ** 2 / sigma ** 2 / 2)\n",
+    "\n",
+    "    def loss(simplex, values, value_scale):\n",
+    "        distance = np.mean([abs(y_iso * value_scale - y) for y in values])\n",
+    "        return priority * gaussian(distance, 0, sigma) + default_loss(simplex, values, value_scale)\n",
+    "\n",
+    "    return loss\n",
+    "\n",
+    "\n",
+    "level = 0.1\n",
+    "loss = isoline_loss_function(level, sigma=0.4, priority=0.5)\n",
+    "\n",
+    "with_tri = True\n",
+    "fig, axs = plt.subplots(1, 2, figsize=(fig_width, fig_height))\n",
+    "plt.subplots_adjust(wspace=0.3)\n",
+    "\n",
+    "for i, ax in enumerate(axs.flatten()):\n",
+    "    learner = adaptive.LearnerND(f, bounds, loss_per_simplex=loss)\n",
+    "    label = \"ab\"[i]\n",
+    "    ax.text(\n",
+    "        0.5,\n",
+    "        1.05,\n",
+    "        f\"$\\mathrm{{({label})}}$\",\n",
+    "        transform=ax.transAxes,\n",
+    "        horizontalalignment=\"center\",\n",
+    "        verticalalignment=\"bottom\",\n",
+    "    )\n",
+    "    ax.xaxis.set_ticks([])\n",
+    "    ax.yaxis.set_ticks([])\n",
+    "    kind = \"homogeneous\" if i == 0 else \"adaptive\"\n",
+    "\n",
+    "    if kind == \"homogeneous\":\n",
+    "        xs, ys = [np.linspace(*bound, int(npoints**0.5)) for bound in bounds]\n",
+    "        data = {xy: f(xy) for xy in itertools.product(xs, ys)}\n",
+    "        learner.data = data\n",
+    "    elif kind == \"adaptive\":\n",
+    "        learner.data = {\n",
+    "            k: v for i, (k, v) in enumerate(learner.data.items()) if i <= npoints\n",
+    "        }\n",
+    "        adaptive.runner.simple(learner, goal=lambda l: l.npoints >= npoints)\n",
+    "\n",
+    "    if with_tri:\n",
+    "        xy = np.array([learner.tri.get_vertices(s)\n",
+    "                           for s in learner.tri.simplices]).reshape(-1, 2)\n",
+    "        triang = mtri.Triangulation(*xy.T)\n",
+    "        ax.triplot(triang, c=\"w\", lw=0.2, alpha=0.8)\n",
+    "\n",
+    "    # Isolines\n",
+    "    vertices, lines = learner._get_iso(level, which='line')\n",
+    "    paths = np.array([[vertices[i], vertices[j]] for i, j in lines]).T\n",
+    "    ax.plot(*paths, c='k')\n",
+    "\n",
+    "    values = np.array(list(learner.data.values()))\n",
+    "    ax.imshow(\n",
+    "        learner.plot(npoints if kind == \"homogeneous\" else None).Image.I.data,\n",
+    "        extent=(-1, 1, -1, 1),\n",
+    "        interpolation=\"none\",\n",
+    "    )\n",
+    "    ax.set_xticks([])\n",
+    "    ax.set_yticks([])\n",
+    "\n",
+    "axs[0].set_ylabel(r\"$\\textrm{homogeneous}$\")\n",
+    "axs[1].set_ylabel(r\"$\\textrm{adaptive}$\")\n",
+    "plt.savefig(\"figures/isoline.pdf\", bbox_inches=\"tight\", transparent=True)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
   }
  ],
  "metadata": {
diff --git a/figures/isoline.pdf b/figures/isoline.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..8eb65518621163015de1a5a30931091ef794cf98
Binary files /dev/null and b/figures/isoline.pdf differ
diff --git a/paper.md b/paper.md
index 093ae89a07c9a81ff9b4677c6141e88d51808ce2..e44df2a5dae5126f1beec060fe1413fb75eed837 100755
--- a/paper.md
+++ b/paper.md
@@ -230,8 +230,14 @@ Here, we see that for homogeneous sampling to get the same error as sampling wit
 
 #### The `cquad` algorithm belongs to a class that is parallelizable.
 
-## isosurface sampling
-<!-- figure here -->
+## isoline and isosurface sampling
+We can find isolines or isosurfaces using a loss function that prioritizes intervals that are closer to the function values that we are interested in.
+See @fig:isoline.
+
+![Comparison of isoline sampling of $f(x,y)=x^2 + y^3$ at $f(x,y)=0.1$ using homogeneous sampling (left) and adaptive sampling (right).
+We plot the function interpolated on a grid (color) with the triangulation on top (white) where the function is sampled on the vertices.
+The solid line (black) indicates the isoline at $f(x,y)=0.1$.
+](figures/isoline.pdf){#fig:isoline}
 
 # Implementation and benchmarks