diff --git a/src/SemiSmoothNewton.jl b/src/SemiSmoothNewton.jl
index 47b41372fbecc66049c15a2184f0bfc212fa6425..7a73355b1145a1bd23853f5e5226a1e1d49c86d0 100644
--- a/src/SemiSmoothNewton.jl
+++ b/src/SemiSmoothNewton.jl
@@ -1,9 +1,9 @@
 module SemiSmoothNewton
 
-include("image.jl")
 include("mesh.jl")
 include("function.jl")
 include("operator.jl")
+include("image.jl")
 
 function clip_line(r0, r1, l0, l1)
     d_l = -(l1[1] - l0[1])
diff --git a/src/function.jl b/src/function.jl
index dfc7cfcd5f8cb98140cabe711f118d90b83d6272..6216bd9dc551dd62dcf12b23c3ad36e30f42ac07 100644
--- a/src/function.jl
+++ b/src/function.jl
@@ -257,31 +257,13 @@ function evaluate(df::Derivative, x)
 end
 
 
-# TODO: inherit from some abstract function type
-struct ImageFunction{Img}
-    mesh::Mesh
-    img::Img
-    cell::Base.RefValue{Int}
-end
-
-ImageFunction(mesh, img) =
-    ImageFunction(mesh, img, Ref(1))
-
-bind!(f::ImageFunction, cell) = f.cell[] = cell
-img_coord(img, x) = (size(img, 1) - x[2] + 1, x[1])
-# TODO: precompute the offset from minimum mesh vertex
-evaluate(f::ImageFunction, xloc) =
-    interpolate_bilinear(f.img,
-        img_coord(f.img, elmap(f.mesh, f.cell[])(xloc) .+ (0.5, 0.5)))
-
-
 struct FacetDivergence{F}
     f::F
     cell::Base.RefValue{Int}
     edgemap::Dict{NTuple{2, Int}, Vector{Int}}
 end
 
-# TODO: incomplete!
+# TODO: unfinished!
 function FacetDivergence(f::FeFunction)
     f.space.element == DP0() || throw(ArgumentError("unimplemented"))
     mesh = f.space.mesh
@@ -324,59 +306,6 @@ end
 bind!(f::FacetDivergence, cell) = f.cell[] = cell
 
 
-# evaluate the function on a rectangular grid of coordinates starting at (0.5,
-# 0.5) with integer spacing, i.e. pixel center coordinates of a bounding image.
-# indexing as for images: first index is y downwards, second index is x
-# rightwards
-# TODO: get rid of this hack as soon as we use size=() for scalar functions
-function sample(f::FeFunction)
-    out = _sample(f)
-    return f.space.size == (1,) ?
-        reshape(out, size(out)[2:end]) : out
-end
-
-function _sample(f::FeFunction)
-    # assumes dual grid
-    mesh = f.space.mesh
-
-    m0 = NTuple{2}(minimum(mesh.vertices, dims = 2))
-    m1 = NTuple{2}(maximum(mesh.vertices, dims = 2))
-
-    fcolon = ntuple(_ -> :, length(f.space.size))
-
-    outsize = (f.space.size..., round.(Int, m1 .- m0 .+ 1)...)
-    out = similar(f.data, outsize)
-    for cell in cells(mesh)
-        bind!(f, cell)
-	A = SArray{Tuple{ndims_space(mesh), nvertices_cell(mesh)}}(
-	    view(mesh.vertices, :, view(mesh.cells, :, cell)))
-
-        # TODO: cleanup when minimum/maximum with dims on StaticArrays becomes
-        # type-stable
-	I0 = round.(Int, SVector(minimum(A[1, :]), minimum(A[2, :])) .- m0) .+ 1
-	I1 = round.(Int, SVector(maximum(A[1, :]), maximum(A[2, :])) .- m0) .+ 1
-
-	for I in CartesianIndex(I0[1], I0[2]):CartesianIndex(I1[1], I1[2])
-            x = SVector(Tuple(I) .- 1 .+ m0)
-            # TODO: move to global-to-local function or inside-triangle test
-            xloc = (A[:, SUnitRange(2, 3)] .- A[:, 1])::SArray \
-                (x .- A[:, 1])
-            ε = eps()
-            if all(xloc .>= -ε) && sum(xloc) <= 1 + ε
-                out[fcolon..., I] .= evaluate(f, xloc)
-            end
-	end
-    end
-
-    # convert to image indexing
-    d = length(f.space.size)
-    reverse!(out, dims = d + 2)
-    out2 = permutedims(out, (ntuple(identity, d)..., d + 2, d + 1))
-
-    return out2
-end
-
-
 prolong!(new_f, old_cell, new_cells) =
     prolong!(new_f, old_cell, new_cells, new_f.space.element)
 
diff --git a/src/image.jl b/src/image.jl
index 0ea64e5d2601d39f2826e0a3a89449d8a88e85fa..dfab9b856d2072fa3deef2b0cb460ee6d5ab4ecd 100644
--- a/src/image.jl
+++ b/src/image.jl
@@ -1,5 +1,27 @@
 export interpolate_bilinear, halve, warp_backwards
 
+# ImageFunction
+
+# TODO: inherit from some abstract function type
+struct ImageFunction{Img}
+    mesh::Mesh
+    img::Img
+    cell::Base.RefValue{Int}
+end
+
+ImageFunction(mesh, img) =
+    ImageFunction(mesh, img, Ref(1))
+
+bind!(f::ImageFunction, cell) = f.cell[] = cell
+# transform coordinates to image/matrix indexing space
+img_coord(img, x) = (size(img, 1) - x[2] + 1, x[1])
+evaluate(f::ImageFunction, xloc) =
+    interpolate_bilinear(f.img,
+        img_coord(f.img, elmap(f.mesh, f.cell[])(xloc)))
+
+# FIXME: unused?
+from_img(img) = permutedims(reverse(arr; dims = 1))
+
 eval_neumann(img, x) = img[clamp.(Tuple(x), axes(img))...]
 
 # uses native array indexing, i.e. 1:n
@@ -44,3 +66,311 @@ function halve(img)
 
     return res
 end
+
+# Sampling
+
+# evaluate the function on a rectangular grid of coordinates with integer
+# spacing bounded by rounded minimum and maximum vertex coordinates.
+# i.e. pixel center coordinates of an image. output indexing as for images:
+# first index is y downwards, second index is x rightwards
+# TODO: get rid of this hack as soon as we use size=() for scalar functions
+function sample(f::FeFunction)
+    out = _sample(f)
+    return f.space.size == (1,) ?
+        reshape(out, size(out)[2:end]) : out
+end
+
+function _sample(f::FeFunction)
+    # assumes dual grid
+    mesh = f.space.mesh
+
+    m0 = NTuple{2}(minimum(mesh.vertices, dims = 2))
+    m1 = NTuple{2}(maximum(mesh.vertices, dims = 2))
+
+    fcolon = ntuple(_ -> :, length(f.space.size))
+
+    outsize = (f.space.size..., round.(Int, m1 .- m0 .+ 1)...)
+    out = similar(f.data, outsize)
+    for cell in cells(mesh)
+        bind!(f, cell)
+	A = SArray{Tuple{ndims_space(mesh), nvertices_cell(mesh)}}(
+	    view(mesh.vertices, :, view(mesh.cells, :, cell)))
+
+        # TODO: cleanup when minimum/maximum with dims on StaticArrays becomes
+        # type-stable
+	I0 = round.(Int, SVector(minimum(A[1, :]), minimum(A[2, :])) .- m0) .+ 1
+	I1 = round.(Int, SVector(maximum(A[1, :]), maximum(A[2, :])) .- m0) .+ 1
+
+	for I in CartesianIndex(I0[1], I0[2]):CartesianIndex(I1[1], I1[2])
+            x = SVector(Tuple(I) .- 1 .+ m0)
+            # TODO: move to global-to-local function or inside-triangle test
+            xloc = (A[:, SUnitRange(2, 3)] .- A[:, 1])::SArray \
+                (x .- A[:, 1])
+            ε = eps()
+            if all(xloc .>= -ε) && sum(xloc) <= 1 + ε
+                out[fcolon..., I] .= evaluate(f, xloc)
+            end
+	end
+    end
+
+    # convert to image indexing
+    d = length(f.space.size)
+    reverse!(out, dims = d + 2) # reverse y
+    out2 = permutedims(out, (ntuple(identity, d)..., d + 2, d + 1)) # flip xy
+
+    return out2
+end
+
+# Interpolation
+
+"""
+interprets `img` as a bilinearly interpolated continuous function and applies
+the default interpolation function for the discrete function u.
+"""
+function interpolate!(u, img)
+    f = ImageFunction(u.space.mesh, img)
+    interpolate!(u, @inline (x; f) -> f; f)
+end
+
+project_img(space::FeSpace, img) =
+    (u = FeFunction(space); project_img!(u, img))
+
+function project_img!(u::FeFunction, img)
+    d = 2 # domain dimension
+    space = u.space
+    mesh = space.mesh
+    f = ImageFunction(mesh, img)
+    opparams = (; f)
+
+    nrdims = prod(space.size)
+    # number of element dofs (i.e. local dofs not counting range dimensions)
+    nldofs = ndofs(space.element)
+
+    a(xloc, u, du, v, dv; f) = dot(u, v)
+    l(xloc, v, dv; f) = dot(f, v)
+
+    I = Float64[]
+    J = Float64[]
+    V = Float64[]
+    b = zeros(ndofs(space))
+
+    # mesh cells
+    for cell in cells(mesh)
+	foreach(f -> bind!(f, cell), opparams)
+	# cell map is assumed to be constant per cell
+	delmap = jacobian(elmap(mesh, cell), SA[0., 0.])
+	delmapinv = inv(delmap)
+	intel = abs(det(delmap))
+
+	p = ceil(Int, diam(mesh, cell))
+	qw, qx = quadrature_composite_lagrange_midpoint(p)
+	nqpts = length(qw) # number of quadrature points
+
+	qphi = zeros(nrdims, nrdims, nldofs, nqpts)
+	dqphi = zeros(nrdims, d, nrdims, nldofs, nqpts)
+	for k in axes(qx, 2)
+	    for r in 1:nrdims
+		qphi[r, r, :, k] .= evaluate_basis(space.element, SVector{d}(view(qx, :, k)))
+		dqphi[r, :, r, :, k] .= transpose(jacobian(x -> evaluate_basis(space.element, x), SVector{d}(view(qx, :, k))))
+	    end
+	end
+
+	# quadrature points
+	for k in axes(qx, 2)
+	    xhat = SVector{d}(view(qx, :, k))
+	    x = elmap(mesh, cell)(xhat)
+	    opvalues = map(f -> evaluate(f, xhat), opparams)
+
+	    # local test-function dofs
+	    for jdim in 1:nrdims, ldofj in 1:nldofs
+		gdofj = space.dofmap[jdim, ldofj, cell]
+
+		phij = SArray{Tuple{space.size...}}(view(qphi, :, jdim, ldofj, k))
+		dphij = SArray{Tuple{space.size..., d}}(
+		    SArray{Tuple{nrdims, d}}(view(dqphi, :, :, jdim, ldofj, k)) * delmapinv)
+
+		lv = qw[k] * l(x, phij, dphij; opvalues...) * intel
+		b[gdofj] += lv
+
+		# local trial-function dofs
+		for idim in 1:nrdims, ldofi in 1:nldofs
+		    gdofi = space.dofmap[idim, ldofi, cell]
+
+		    phii = SArray{Tuple{space.size...}}(view(qphi, :, idim, ldofi, k))
+		    dphii = SArray{Tuple{space.size..., d}}(
+			SArray{Tuple{nrdims, d}}(view(dqphi, :, :, idim, ldofi, k)) * delmapinv)
+
+		    av = qw[k] * a(x, phii, dphii, phij, dphij; opvalues...) * intel
+		    push!(I, gdofi)
+		    push!(J, gdofj)
+		    push!(V, av)
+		end
+	    end
+	end
+    end
+
+    ngdofs = ndofs(space)
+    A = sparse(I, J, V, ngdofs, ngdofs)
+
+    u.data .= A \ b
+    return u
+end
+
+project_img2(space::FeSpace, img) =
+    (u = FeFunction(space); project_img2!(u, img))
+
+# L1-stable quasi-interpolation operator
+# (https://arxiv.org/pdf/1505.06931.pdf)
+#
+# FIXME: currently only approximate quadrature by sampling on lagrange lattice
+# based on element size. Exact evaluation is tricky to implement (lots of
+# intersection handling).
+function project_img2!(u::FeFunction, img)
+    u.space.element == P1() ||
+        throw(ArgumentError("element unsupported"))
+    d = 2 # domain dimension
+    space = u.space
+    mesh = space.mesh
+    f = ImageFunction(mesh, img)
+    # count contributions to respective dof for subsequent averaging
+    gdofcount = zeros(Int, size(u.data))
+    area_refel = 0.5
+
+    u.data .= zero(eltype(u.data))
+    for cell in cells(mesh)
+        bind!(f, cell)
+
+        # size: nrdims × nldofs
+        # TODO: should be statically sized
+        gdofs = u.space.dofmap[:, :, cell]
+
+        p = ceil(Int, diam(mesh, cell))
+        qw, qx = quadrature_composite_lagrange_midpoint(p)
+	for k in axes(qx, 2)
+	    xhat = SVector{d}(view(qx, :, k))
+	    x = elmap(mesh, cell)(xhat)
+
+            # size: nrdims
+            value = evaluate(f, xhat)
+            # size: nldofs
+            phix = evaluate_basis(space.element, xhat)
+
+            # no integration element used
+            u.data[gdofs] .+= qw[k] ./ area_refel .* 3 .* value * phix'
+            #u.data[gdofs] .+= qw[k] ./ area_refel * value * (12 .* phix' .- 3)
+	end
+        gdofcount[gdofs] .+= 1
+    end
+    u.data ./= gdofcount
+    return u
+end
+
+# FIXME: unfinished / unused -> remove?
+function quadrature_cell_pixels(mesh, cell; m0)
+    d = ndims_domain(mesh)
+    A = SArray{Tuple{ndims_space(mesh), nvertices_cell(mesh)}}(
+        view(mesh.vertices, :, view(mesh.cells, :, cell)))
+
+    # TODO: cleanup when minimum/maximum with dims on StaticArrays becomes
+    # type-stable
+    I0 = round.(Int, SVector(minimum(A[1, :]), minimum(A[2, :])) .- m0) .+ 1
+    I1 = round.(Int, SVector(maximum(A[1, :]), maximum(A[2, :])) .- m0) .+ 1
+
+    #quadrature() = SA[1/6, 1/6, 1/6], SA[1/6 4/6 1/6; 1/6 1/6 4/6]
+    vertices = Float64[]
+    for I in CartesianIndex(I0[1], I0[2]):CartesianIndex(I1[1], I1[2])
+        x = SVector(Tuple(I) .- 1 .+ m0)
+        # TODO: move to global-to-local function or inside-triangle test
+        xloc = (A[:, SUnitRange(2, 3)] .- A[:, 1])::SArray \
+            (x .- A[:, 1])
+        ε = eps()
+        if all(xloc .>= -ε) && sum(xloc) <= 1 + ε
+            append!(vertices, xloc)
+        end
+    end
+    qw = ones(length(vertices) ÷ d) ./ length(vertices)
+    qx = reshape!(vertices, (d, :))
+end
+
+
+function interpolate_img_l2pixel!(u::FeFunction, img)
+    d = 2 # domain dimension
+    space = u.space
+    mesh = space.mesh
+    f = ImageFunction(mesh, img)
+    opparams = (; f)
+
+    nrdims = prod(space.size)
+    # number of element dofs (i.e. local dofs not counting range dimensions)
+    nldofs = ndofs(space.element)
+
+    a(xloc, u, du, v, dv; f) = dot(u, v)
+    l(xloc, v, dv; f) = dot(f, v)
+
+    I = Float64[]
+    J = Float64[]
+    V = Float64[]
+    b = zeros(ndofs(space))
+
+    # mesh cells
+    for cell in cells(mesh)
+	foreach(f -> bind!(f, cell), opparams)
+	# cell map is assumed to be constant per cell
+	delmap = jacobian(elmap(mesh, cell), SA[0., 0.])
+	delmapinv = inv(delmap)
+	intel = abs(det(delmap))
+
+	p = ceil(Int, diam(mesh, cell))
+	qw, qx = quadrature_composite_lagrange_midpoint(p)
+	nqpts = length(qw) # number of quadrature points
+
+	qphi = zeros(nrdims, nrdims, nldofs, nqpts)
+	dqphi = zeros(nrdims, d, nrdims, nldofs, nqpts)
+	for k in axes(qx, 2)
+	    for r in 1:nrdims
+		qphi[r, r, :, k] .= evaluate_basis(space.element, SVector{d}(view(qx, :, k)))
+		dqphi[r, :, r, :, k] .= transpose(jacobian(x -> evaluate_basis(space.element, x), SVector{d}(view(qx, :, k))))
+	    end
+	end
+
+	# quadrature points
+	for k in axes(qx, 2)
+	    xhat = SVector{d}(view(qx, :, k))
+	    x = elmap(mesh, cell)(xhat)
+	    opvalues = map(f -> evaluate(f, xhat), opparams)
+
+	    # local test-function dofs
+	    for jdim in 1:nrdims, ldofj in 1:nldofs
+		gdofj = space.dofmap[jdim, ldofj, cell]
+
+		phij = SArray{Tuple{space.size...}}(view(qphi, :, jdim, ldofj, k))
+		dphij = SArray{Tuple{space.size..., d}}(
+		    SArray{Tuple{nrdims, d}}(view(dqphi, :, :, jdim, ldofj, k)) * delmapinv)
+
+		lv = qw[k] * l(x, phij, dphij; opvalues...) * intel
+		b[gdofj] += lv
+
+		# local trial-function dofs
+		for idim in 1:nrdims, ldofi in 1:nldofs
+		    gdofi = space.dofmap[idim, ldofi, cell]
+
+		    phii = SArray{Tuple{space.size...}}(view(qphi, :, idim, ldofi, k))
+		    dphii = SArray{Tuple{space.size..., d}}(
+			SArray{Tuple{nrdims, d}}(view(dqphi, :, :, idim, ldofi, k)) * delmapinv)
+
+		    av = qw[k] * a(x, phii, dphii, phij, dphij; opvalues...) * intel
+		    push!(I, gdofi)
+		    push!(J, gdofj)
+		    push!(V, av)
+		end
+	    end
+	end
+    end
+
+    ngdofs = ndofs(space)
+    A = sparse(I, J, V, ngdofs, ngdofs)
+
+    u.data .= A \ b
+    return u
+
+end
diff --git a/src/mesh.jl b/src/mesh.jl
index a8c5c2e09056dee70516ff9d3836062e6cbc1e33..7177c01e4e8c679106e116dee01b946a365da014 100644
--- a/src/mesh.jl
+++ b/src/mesh.jl
@@ -1,5 +1,5 @@
 export init_grid, init_hgrid, save, refine!, refine, cells, vertices,
-    ndims_domain, ndims_space
+    ndims_domain, ndims_space, nvertices, nvertices_cell, ncells
 
 using LinearAlgebra: norm
 
@@ -17,7 +17,9 @@ Base.show(io::IO, ::MIME"text/plain", x::HMesh) =
 
 ndims_domain(::HMesh) = 2
 ndims_space(::HMesh) = 2
+# TODO: move to simplex mesh interface
 nvertices_cell(::HMesh) = 3
+# TODO: do we even these two?
 nvertices(x::HMesh) = length(x.vertices)
 ncells(x::HMesh) = length(x.cells)
 
diff --git a/src/operator.jl b/src/operator.jl
index c77afc464faedb674ec5a75ebffd70ca339dfe58..ea428e8ea6098d47ef335867dff539f83b139fa4 100644
--- a/src/operator.jl
+++ b/src/operator.jl
@@ -116,11 +116,6 @@ function assemble(space::FeSpace, a, l; params...)
     return A, b
 end
 
-from_img(arr) = permutedims(reverse(arr; dims = 1))
-
-project_img(space::FeSpace, img) =
-    (u = FeFunction(space); project_img!(u, img))
-
 # composite midpoint quadrature on 2d-lagrange point lattice
 function quadrature_composite_lagrange_midpoint(p)
     d_ = 2
@@ -139,133 +134,3 @@ function quadrature_composite_lagrange_midpoint(p)
 
     return weights, points
 end
-
-function project_img!(u::FeFunction, img)
-    d = 2 # domain dimension
-    space = u.space
-    mesh = space.mesh
-    f = ImageFunction(mesh, img)
-    opparams = (; f)
-
-    nrdims = prod(space.size)
-    # number of element dofs (i.e. local dofs not counting range dimensions)
-    nldofs = ndofs(space.element)
-
-    a(xloc, u, du, v, dv; f) = dot(u, v)
-    l(xloc, v, dv; f) = dot(f, v)
-
-    I = Float64[]
-    J = Float64[]
-    V = Float64[]
-    b = zeros(ndofs(space))
-
-    # mesh cells
-    for cell in cells(mesh)
-	foreach(f -> bind!(f, cell), opparams)
-	# cell map is assumed to be constant per cell
-	delmap = jacobian(elmap(mesh, cell), SA[0., 0.])
-	delmapinv = inv(delmap)
-	intel = abs(det(delmap))
-
-	p = ceil(Int, diam(mesh, cell))
-	qw, qx = quadrature_composite_lagrange_midpoint(p)
-	nqpts = length(qw) # number of quadrature points
-
-	qphi = zeros(nrdims, nrdims, nldofs, nqpts)
-	dqphi = zeros(nrdims, d, nrdims, nldofs, nqpts)
-	for k in axes(qx, 2)
-	    for r in 1:nrdims
-		qphi[r, r, :, k] .= evaluate_basis(space.element, SVector{d}(view(qx, :, k)))
-		dqphi[r, :, r, :, k] .= transpose(jacobian(x -> evaluate_basis(space.element, x), SVector{d}(view(qx, :, k))))
-	    end
-	end
-
-	# quadrature points
-	for k in axes(qx, 2)
-	    xhat = SVector{d}(view(qx, :, k))
-	    x = elmap(mesh, cell)(xhat)
-	    opvalues = map(f -> evaluate(f, xhat), opparams)
-
-	    # local test-function dofs
-	    for jdim in 1:nrdims, ldofj in 1:nldofs
-		gdofj = space.dofmap[jdim, ldofj, cell]
-
-		phij = SArray{Tuple{space.size...}}(view(qphi, :, jdim, ldofj, k))
-		dphij = SArray{Tuple{space.size..., d}}(
-		    SArray{Tuple{nrdims, d}}(view(dqphi, :, :, jdim, ldofj, k)) * delmapinv)
-
-		lv = qw[k] * l(x, phij, dphij; opvalues...) * intel
-		b[gdofj] += lv
-
-		# local trial-function dofs
-		for idim in 1:nrdims, ldofi in 1:nldofs
-		    gdofi = space.dofmap[idim, ldofi, cell]
-
-		    phii = SArray{Tuple{space.size...}}(view(qphi, :, idim, ldofi, k))
-		    dphii = SArray{Tuple{space.size..., d}}(
-			SArray{Tuple{nrdims, d}}(view(dqphi, :, :, idim, ldofi, k)) * delmapinv)
-
-		    av = qw[k] * a(x, phii, dphii, phij, dphij; opvalues...) * intel
-		    push!(I, gdofi)
-		    push!(J, gdofj)
-		    push!(V, av)
-		end
-	    end
-	end
-    end
-
-    ngdofs = ndofs(space)
-    A = sparse(I, J, V, ngdofs, ngdofs)
-
-    u.data .= A \ b
-    return u
-end
-
-project_img2(space::FeSpace, img) =
-    (u = FeFunction(space); project_img2!(u, img))
-
-# L1-stable quasi-interpolation operator
-# (https://arxiv.org/pdf/1505.06931.pdf)
-#
-# FIXME: currently only approximate quadrature by sampling on lagrange lattice
-# based on element size. Exact evaluation is tricky to implement (lots of
-# intersection handling).
-function project_img2!(u::FeFunction, img)
-    u.space.element == P1() ||
-        throw(ArgumentError("element unsupported"))
-    d = 2 # domain dimension
-    space = u.space
-    mesh = space.mesh
-    f = ImageFunction(mesh, img)
-    # count contributions to respective dof for subsequent averaging
-    gdofcount = zeros(Int, size(u.data))
-    area_refel = 0.5
-
-    u.data .= zero(eltype(u.data))
-    for cell in cells(mesh)
-        bind!(f, cell)
-
-        # size: nrdims × nldofs
-        # TODO: should be statically sized
-        gdofs = u.space.dofmap[:, :, cell]
-
-        p = ceil(Int, diam(mesh, cell))
-        qw, qx = quadrature_composite_lagrange_midpoint(p)
-	for k in axes(qx, 2)
-	    xhat = SVector{d}(view(qx, :, k))
-	    x = elmap(mesh, cell)(xhat)
-
-            # size: nrdims
-            value = evaluate(f, xhat)
-            # size: nldofs
-            phix = evaluate_basis(space.element, xhat)
-
-            # no integration element used
-            u.data[gdofs] .+= qw[k] ./ area_refel .* 3 .* value * phix'
-            #u.data[gdofs] .+= qw[k] ./ area_refel * value * (12 .* phix' .- 3)
-	end
-        gdofcount[gdofs] .+= 1
-    end
-    u.data ./= gdofcount
-    return u
-end
diff --git a/test/Manifest.toml b/test/Manifest.toml
index 68a4dcd5eff907460e1c421cffd2020ad2e1aae5..25f1e8e3a1f6f3fd1e7233296bafddbf4dc6298f 100644
--- a/test/Manifest.toml
+++ b/test/Manifest.toml
@@ -1,26 +1,32 @@
 # This file is machine-generated - editing it directly is not advised
 
-[[Base64]]
+julia_version = "1.7.0"
+manifest_format = "2.0"
+
+[[deps.Base64]]
 uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
 
-[[InteractiveUtils]]
+[[deps.InteractiveUtils]]
 deps = ["Markdown"]
 uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
 
-[[Logging]]
+[[deps.Logging]]
 uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
 
-[[Markdown]]
+[[deps.Markdown]]
 deps = ["Base64"]
 uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
 
-[[Random]]
-deps = ["Serialization"]
+[[deps.Random]]
+deps = ["SHA", "Serialization"]
 uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
 
-[[Serialization]]
+[[deps.SHA]]
+uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
+
+[[deps.Serialization]]
 uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
 
-[[Test]]
+[[deps.Test]]
 deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
 uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
diff --git a/test/runtests.jl b/test/runtests.jl
index 6bc28ce2831b0b5a34258575a4c49ca578c7e1f3..906ec15f8b29ea39d1416286715e93bda9e0de79 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -12,17 +12,19 @@ Random.seed!(0)
     @test ndims_domain(mesh) == 2
     @test ndims_space(mesh) == 2
 
+    # dual mesh: vertices are pixel centers
+    @test nvertices(mesh) == 3^2
+    @test ncells(mesh) == 2 * 2^2 # TODO: only simplex mesh
     v0 = minimum(mesh.vertices, dims = 2) |> vec
     v1 = maximum(mesh.vertices, dims = 2) |> vec
-
     @test v0 == [1., 1.]
     @test v1 == [3., 3.]
 
-    f_space = FeSpace(mesh, P1(), (1,))
-    f = FeFunction(f_space)
+    u_space = FeSpace(mesh, P1(), (1,))
+    u = FeFunction(u_space)
 
-    interpolate!(f, x -> interpolate_bilinear(img, x))
-    img_sampled = sample(f)
+    interpolate!(u, img)
+    img_sampled = sample(u)
 
     @test img == img_sampled
 end