diff --git a/grudge/geometry/__init__.py b/grudge/geometry/__init__.py
index 02dbc5f155e4d2f0058208b98075e17749222250..4106888f42bf4e2dd5bb94ad90622b3cc4a8955f 100644
--- a/grudge/geometry/__init__.py
+++ b/grudge/geometry/__init__.py
@@ -27,12 +27,14 @@ from grudge.geometry.metrics import (
     forward_metric_derivative_mat,
     inverse_metric_derivative_mat,
     inverse_surface_metric_derivative,
-    area_element
+    area_element,
+    normal
 )
 
 __all__ = (
     "forward_metric_derivative_mat",
     "inverse_metric_derivative_mat",
     "inverse_surface_metric_derivative",
-    "area_element"
+    "area_element",
+    "normal"
 )
diff --git a/grudge/geometry/metrics.py b/grudge/geometry/metrics.py
index a879c861bf85f447f4b03055ee97d3f59129b5da..b8a109e4075aa0835f5bdb08a27620a3bbc9bddd 100644
--- a/grudge/geometry/metrics.py
+++ b/grudge/geometry/metrics.py
@@ -271,3 +271,60 @@ def area_element(actx, dcoll, dd=None):
     return actx.np.sqrt(
         pseudoscalar(actx, dcoll, dd=dd).norm_squared()
     )
+
+
+def surface_normal(actx, dcoll, dd=None, dim=None):
+    import grudge.dof_desc as dof_desc
+
+    dd = dof_desc.as_dofdesc(dd)
+    dim = dim or dcoll.dim
+
+    # NOTE: Don't be tempted to add a sign here. As it is, it produces
+    # exterior normals for positively oriented curves.
+
+    pder = pseudoscalar(actx, dcoll, dd=dd) / area_element(actx, dcoll, dd=dd)
+
+    # Dorst Section 3.7.2
+    return pder << pder.I.inv()
+
+
+def mv_normal(actx, dcoll, dd):
+    """Exterior unit normal as a :class:`~pymbolic.geometric_algebra.MultiVector`."""
+    import grudge.dof_desc as dof_desc
+
+    dd = dof_desc.as_dofdesc(dd)
+    if not dd.is_trace():
+        raise ValueError("may only request normals on boundaries")
+
+    dim = dcoll.dim
+    ambient_dim = dcoll.ambient_dim
+
+    if dim == ambient_dim - 1:
+        return surface_normal(actx, dcoll, dd=dd)
+
+    # NOTE: In the case of (d - 2)-dimensional curves, we don't really have
+    # enough information on the face to decide what an "exterior face normal"
+    # is (e.g the "normal" to a 1D curve in 3D space is actually a
+    # "normal plane")
+    #
+    # The trick done here is that we take the surface normal, move it to the
+    # face and then take a cross product with the face tangent to get the
+    # correct exterior face normal vector.
+    assert dim == ambient_dim - 2
+
+    from grudge.operators import project
+    import grudge.dof_desc as dof_desc
+
+    volm_normal = project(dcoll, dof_desc.DD_VOLUME, dd,
+                          surface_normal(actx, dcoll,
+                                         dd=dof_desc.DD_VOLUME,
+                                         dim=dim + 1))
+    pder = pseudoscalar(actx, dcoll, dd=dd)
+
+    mv = -(volm_normal ^ pder) << volm_normal.I.inv()
+
+    return mv / actx.np.sqrt(mv.norm_squared())
+
+
+def normal(actx, dcoll, dd):
+    return mv_normal(actx, dcoll, dd).as_vector()
diff --git a/grudge/operators.py b/grudge/operators.py
index f157810995f1021c8892046bb30a1934d5979217..22d290129316e02346fa2dfb601c6960e46c42a7 100644
--- a/grudge/operators.py
+++ b/grudge/operators.py
@@ -22,9 +22,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 """
 
-
+from numbers import Number
 from pytools import (
-    keyed_memoize_in, memoize_in, memoize_on_first_arg
+    memoize_in,
+    memoize_on_first_arg,
+    # keyed_memoize_in
 )
 from pytools.obj_array import obj_array_vectorize
 from meshmode.array_context import (
@@ -48,6 +50,60 @@ class MassOperatorTag(HasElementwiseMatvecTag):
 # }}}
 
 
+def project(dcoll, src, tgt, vec):
+    """Project from one discretization to another, e.g. from the
+    volume to the boundary, or from the base to the an overintegrated
+    quadrature discretization.
+
+    :arg src: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one
+    :arg tgt: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
+    """
+    src = dof_desc.as_dofdesc(src)
+    tgt = dof_desc.as_dofdesc(tgt)
+    if src == tgt:
+        return vec
+
+    if isinstance(vec, np.ndarray):
+        return obj_array_vectorize(
+                lambda el: project(dcoll, src, tgt, el), vec)
+
+    if isinstance(vec, Number):
+        return vec
+
+    return dcoll.connection_from_dds(src, tgt)(vec)
+
+
+# {{{ geometric properties
+
+def nodes(dcoll, dd=None):
+    r"""Return the nodes of a discretization.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
+        Defaults to the base volume discretization.
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+    """
+    if dd is None:
+        return dcoll._volume_discr.nodes()
+    else:
+        return dcoll.discr_from_dd(dd).nodes()
+
+
+@memoize_on_first_arg
+def normal(dcoll, dd):
+    """Get unit normal to specified surface discretization, *dd*.
+
+    :arg dd: a :class:`~grudge.dof_desc.DOFDesc` as the surface discretization.
+    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`.
+    """
+    from grudge.geometry import normal
+    surface_discr = dcoll.discr_from_dd(dd)
+    actx = surface_discr._setup_actx
+    return actx.freeze(normal(actx, dcoll, dd))
+
+# }}}
+
+
 # {{{ Derivative operator
 
 def reference_derivative_matrices(actx, element_group):
@@ -201,7 +257,7 @@ def reference_stiffness_transpose_matrix(actx, out_element_group, in_element_gro
     #                              in_grp.discretization_key()))
     def get_ref_stiffness_transpose_mat(out_grp, in_grp):
         if in_grp == out_grp:
-            mmat = reference_mass_matrix(actx, in_grp)
+            mmat = reference_mass_matrix(actx, out_grp, in_grp)
             return [dmat.T.dot(mmat.T)
                     for dmat in reference_derivative_matrices(actx, in_grp)]