diff --git a/doc/cpp/cable_cell.rst b/doc/cpp/cable_cell.rst
index 6f42ccd2d70f704f345a4e4def4ff7f604b59beb..14a2b412056e4ef823e2162441dca78b2e51b1bc 100644
--- a/doc/cpp/cable_cell.rst
+++ b/doc/cpp/cable_cell.rst
@@ -41,198 +41,6 @@ on the cell via the ``paint`` method; while synapses, current clamps,
 gap junction locations, and the site for testing the threshold potential
 are specified via the ``place`` method. See :ref:`cppcablecell-dynamics`, below.
 
-.. _cppcablecell-morphology-construction:
-
-Constructing cell morphologies
-------------------------------
-
-Arbor provides an interface for constructing arbitrary cell morphologies
-composed of *segments*, see :ref:`the general documentation <morph>`.
-**TODO** C++ documentation for segment trees is not yet complete.
-
-The stitch-builder interface
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-Like the segment tree, the :cpp:type:`stich_builder` class constructs morphologies
-through attaching simple components described by a pair of :cpp:type:`mpoint` values,
-proximal and distal. These components are :cpp:type:`mstitch` objects, and
-they differ from segments in two regards:
-
-1. Stitches are identified by a unique string identifier, in addition to an optional tag value.
-
-2. Stitches can be attached to a parent stitch at either end, or anywhere in the middle.
-
-The ability to attach a stitch some way along another stitch dictates that one
-stitch may correspond to more than one morphological segment once the morphology
-is fully specified. When these attachment points are internal to a stitch, the
-corresponding geometrical point is determined by linearly interpolating between
-the proximal and distal points.
-
-The required header file is ``arbor/morph/stitch.hpp``.
-
-:cpp:type:`mstitch` has two constructors:
-
-.. code::
-
-   mstitch::mstitch(std::string id, mpoint prox, mpoint dist, int tag = 0)
-   mstitch::mstitch(std::string id, mpoint dist, int tag = 0)
-
-If the proximal point is omitted, it will be inferred from the point at which
-the stitch is attached to its parent.
-
-The :cpp:type:`stitch_builder` class collects the stitches with the ``add`` method:
-
-.. code::
-
-   stitch_builder::add(mstitch, const std::string& parent_id, double along = 1.)
-   stitch_builder::add(mstitch, double along = 1.)
-
-The first stitch will have no parent. If no parent id is specified for a subsequent
-stitch, the last stitch added will be used as parent. The ``along`` parameter
-must lie between zero and one inclusive, and determines the point of attachment
-as a relative position between the parent's proximal and distal points.
-
-A :cpp:type:`stitched_morphology` is constructed from a :cpp:type:`stitch_builder`,
-and provides both the :cpp:type:`morphology` built from the stitches, and methods
-for querying the extent of individual stitches.
-
-.. cpp:class:: stitched_morphology
-
-   .. cpp:function:: stitched_morphology(const stitch_builder&)
-   .. cpp:function:: stitched_morphology(stitch_builder&&)
-
-   Construct from a ``stitch_builder``. Note that constructing from an
-   rvalue is more efficient, as it avoids making a copy of the underlying
-   tree structure.
-
-   .. cpp:function:: arb::morphology morphology() const
-
-   Return the constructed morphology object.
-
-   .. cpp:function:: region stitch(const std::string& id) const
-
-   Return the region expression corresponding to the specified stitch.
-
-   .. cpp:function:: std::vector<msize_t> segments(const std::string& id) const
-
-   Return the collection of segments by index comprising the specified stitch.
-
-   .. cpp:function:: label_dict labels(const std::string& prefix="") const
-
-   Provide a :cpp:type:`label_dict` with a region entry for each stitch; if
-   a prefix is provided, this prefix is applied to each segment id to determine
-   the region labels.
-
-Example code, constructing a cable cell from a T-shaped morphology specified
-by two stitches:
-
-.. code::
-
-   using namespace arb;
-
-   mpoint soma0{0, 0, 0, 10};
-   mpoint soma1{20, 0, 0, 10};
-   mpoint dend_end{10, 100, 0, 1};
-
-   stitch_builder builder;
-   builder.add({"soma", soma0, soma1, 1});
-   builder.add({"dend", dend_end, 4}, "soma", 0.5);
-
-   stitched_morphology stitched(std::move(builder));
-   cable_cell cell(stitched.morphology(), stitched.labels());
-
-   cell.paint("\"soma\"", "hh");
-
-
-Supported morphology formats
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-Arbor supports morphologies described using the SWC file format and the NeuroML file format.
-
-SWC
-"""
-
-Arbor supports reading morphologies described using the
-`SWC <http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html>`_ file format. And
-has three different interpretation of that format.
-
-A :cpp:func:`parse_swc()` function is used to parse the SWC file and generate a :cpp:type:`swc_data` object.
-This object contains a vector of :cpp:type:`swc_record` objects that represent the SWC samples, with a number of
-basic checks performed on them. The :cpp:type:`swc_data` object can then be used to generate a
-:cpp:type:`morphology` object using one of the following functions: (See the morphology concepts
-:ref:`page <morph-formats>` for more details).
-
-  * :cpp:func:`load_swc_arbor`
-  * :cpp:func:`load_swc_allen`
-  * :cpp:func:`load_swc_neuron`
-
-.. cpp:class:: swc_record
-
-   .. cpp:member:: int id
-
-      ID of the record
-
-   .. cpp:member:: int tag
-
-       Structure identifier (tag).
-
-   .. cpp:member:: double x
-
-      x coordinate in space.
-
-   .. cpp:member:: double y
-
-      y coordinate in space.
-
-   .. cpp:member:: double z
-
-      z coordinate in space.
-
-   .. cpp:member:: double r
-
-      Sample radius.
-
-   .. cpp:member:: int parent_id
-
-      Record parent's sample ID.
-
-.. cpp:class:: swc_data
-
-   .. cpp:member:: std::string metadata
-
-      Contains the comments of an SWC file.
-
-   .. cpp:member:: std::vector<swc_record> records
-
-      Stored the list of samples from an SWC file, after performing some checks.
-
-.. cpp:function:: swc_data parse_swc(std::istream&)
-
-   Returns an :cpp:type:`swc_data` object given an std::istream object.
-
-.. cpp:function:: morphology load_swc_arbor(const swc_data& data)
-
-   Returns a :cpp:type:`morphology` constructed according to Arbor's SWC specifications.
-
-.. cpp:function:: morphology load_swc_allen(const swc_data& data, bool no_gaps=false)
-
-   Returns a :cpp:type:`morphology` constructed according to the Allen Institute's SWC
-   specifications. By default, gaps in the morphology are allowed, this can be toggled
-   using the ``no_gaps`` argument.
-
-.. cpp:function:: morphology load_swc_neuron(const swc_data& data)
-
-   Returns a :cpp:type:`morphology` constructed according to NEURON's SWC specifications.
-
-.. _cppcablecell-locsets-and-regions:
-
-Identifying sites and subsets of the morphology
------------------------------------------------
-
-.. todo::
-
-   TODO: Region and locset documentation is under development.
-
 .. _cppcablecell-dynamics:
 
 Cell dynamics
diff --git a/doc/cpp/index.rst b/doc/cpp/index.rst
index b7a7ee0c2a6d3dba4669bcc5b9f0ab0ecebb4e7a..ac5799ff74d60c57825df78d19f10c92e52158a2 100644
--- a/doc/cpp/index.rst
+++ b/doc/cpp/index.rst
@@ -17,6 +17,8 @@ A :cpp:type:`arb::recipe` describes a model, and a :cpp:type:`arb::simulation` i
 
    recipe
    cell
+   cable_cell
+   morphology
    interconnectivity
    hardware
    domdec
diff --git a/doc/cpp/morphology.rst b/doc/cpp/morphology.rst
new file mode 100644
index 0000000000000000000000000000000000000000..46c939f72a6ec2bf5be115b27190a1fcd6970e88
--- /dev/null
+++ b/doc/cpp/morphology.rst
@@ -0,0 +1,316 @@
+.. _cppmorphology:
+
+Cable cell morphologies
+=======================
+
+Cell morphologies are required to describe a :ref:`cppcablecell`.
+Morphologies can be constructed directly, or read from a number of
+file formats; see :ref:`cppcablecell-morphology-construction` for details.
+
+Morphology API
+--------------
+
+.. todo::
+
+   TODO: Describe morphology methods.
+
+.. _cppcablecell-morphology-construction:
+
+Constructing cell morphologies
+------------------------------
+
+.. todo::
+
+   TODO: Description of segment trees.
+
+
+The stitch-builder interface
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Like the segment tree, the :cpp:type:`stich_builder` class constructs morphologies
+through attaching simple components described by a pair of :cpp:type:`mpoint` values,
+proximal and distal. These components are :cpp:type:`mstitch` objects, and
+they differ from segments in two regards:
+
+1. Stitches are identified by a unique string identifier, in addition to an optional tag value.
+
+2. Stitches can be attached to a parent stitch at either end, or anywhere in the middle.
+
+The ability to attach a stitch some way along another stitch dictates that one
+stitch may correspond to more than one morphological segment once the morphology
+is fully specified. When these attachment points are internal to a stitch, the
+corresponding geometrical point is determined by linearly interpolating between
+the proximal and distal points.
+
+The required header file is ``arbor/morph/stitch.hpp``.
+
+:cpp:type:`mstitch` has two constructors:
+
+.. code::
+
+   mstitch::mstitch(std::string id, mpoint prox, mpoint dist, int tag = 0)
+   mstitch::mstitch(std::string id, mpoint dist, int tag = 0)
+
+If the proximal point is omitted, it will be inferred from the point at which
+the stitch is attached to its parent.
+
+The :cpp:type:`stitch_builder` class collects the stitches with the ``add`` method:
+
+.. code::
+
+   stitch_builder::add(mstitch, const std::string& parent_id, double along = 1.)
+   stitch_builder::add(mstitch, double along = 1.)
+
+The first stitch will have no parent. If no parent id is specified for a subsequent
+stitch, the last stitch added will be used as parent. The ``along`` parameter
+must lie between zero and one inclusive, and determines the point of attachment
+as a relative position between the parent's proximal and distal points.
+
+A :cpp:type:`stitched_morphology` is constructed from a :cpp:type:`stitch_builder`,
+and provides both the :cpp:type:`morphology` built from the stitches, and methods
+for querying the extent of individual stitches.
+
+.. cpp:class:: stitched_morphology
+
+   .. cpp:function:: stitched_morphology(const stitch_builder&)
+   .. cpp:function:: stitched_morphology(stitch_builder&&)
+
+   Construct from a ``stitch_builder``. Note that constructing from an
+   rvalue is more efficient, as it avoids making a copy of the underlying
+   tree structure.
+
+   .. cpp:function:: arb::morphology morphology() const
+
+   Return the constructed morphology object.
+
+   .. cpp:function:: region stitch(const std::string& id) const
+
+   Return the region expression corresponding to the specified stitch.
+
+   .. cpp:function:: std::vector<msize_t> segments(const std::string& id) const
+
+   Return the collection of segments by index comprising the specified stitch.
+
+   .. cpp:function:: label_dict labels(const std::string& prefix="") const
+
+   Provide a :cpp:type:`label_dict` with a region entry for each stitch; if
+   a prefix is provided, this prefix is applied to each segment id to determine
+   the region labels.
+
+Example code, constructing a cable cell from a T-shaped morphology specified
+by two stitches:
+
+.. code::
+
+   using namespace arb;
+
+   mpoint soma0{0, 0, 0, 10};
+   mpoint soma1{20, 0, 0, 10};
+   mpoint dend_end{10, 100, 0, 1};
+
+   stitch_builder builder;
+   builder.add({"soma", soma0, soma1, 1});
+   builder.add({"dend", dend_end, 4}, "soma", 0.5);
+
+   stitched_morphology stitched(std::move(builder));
+   cable_cell cell(stitched.morphology(), stitched.labels());
+
+   cell.paint("\"soma\"", "hh");
+
+
+Supported morphology formats
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Arbor supports morphologies described using the SWC file format and the NeuroML file format.
+
+SWC
+"""
+
+Arbor supports reading morphologies described using the
+`SWC <http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html>`_ file format. And
+has three different interpretation of that format.
+
+A :cpp:func:`parse_swc()` function is used to parse the SWC file and generate a :cpp:type:`swc_data` object.
+This object contains a vector of :cpp:type:`swc_record` objects that represent the SWC samples, with a number of
+basic checks performed on them. The :cpp:type:`swc_data` object can then be used to generate a
+:cpp:type:`morphology` object using one of the following functions: (See the morphology concepts
+:ref:`page <morph-formats>` for more details).
+
+  * :cpp:func:`load_swc_arbor`
+  * :cpp:func:`load_swc_allen`
+  * :cpp:func:`load_swc_neuron`
+
+.. cpp:class:: swc_record
+
+   .. cpp:member:: int id
+
+      ID of the record
+
+   .. cpp:member:: int tag
+
+       Structure identifier (tag).
+
+   .. cpp:member:: double x
+
+      x coordinate in space.
+
+   .. cpp:member:: double y
+
+      y coordinate in space.
+
+   .. cpp:member:: double z
+
+      z coordinate in space.
+
+   .. cpp:member:: double r
+
+      Sample radius.
+
+   .. cpp:member:: int parent_id
+
+      Record parent's sample ID.
+
+.. cpp:class:: swc_data
+
+   .. cpp:member:: std::string metadata
+
+      Contains the comments of an SWC file.
+
+   .. cpp:member:: std::vector<swc_record> records
+
+      Stored the list of samples from an SWC file, after performing some checks.
+
+.. cpp:function:: swc_data parse_swc(std::istream&)
+
+   Returns an :cpp:type:`swc_data` object given an std::istream object.
+
+.. cpp:function:: morphology load_swc_arbor(const swc_data& data)
+
+   Returns a :cpp:type:`morphology` constructed according to Arbor's SWC specifications.
+
+.. cpp:function:: morphology load_swc_allen(const swc_data& data, bool no_gaps=false)
+
+   Returns a :cpp:type:`morphology` constructed according to the Allen Institute's SWC
+   specifications. By default, gaps in the morphology are allowed, this can be toggled
+   using the ``no_gaps`` argument.
+
+.. cpp:function:: morphology load_swc_neuron(const swc_data& data)
+
+   Returns a :cpp:type:`morphology` constructed according to NEURON's SWC specifications.
+
+.. _locsets-and-regions:
+
+Identifying sites and subsets of the morphology
+-----------------------------------------------
+
+.. todo::
+
+   TODO: Region and locset documentation.
+
+
+Translating regions and locsets to cables and locations
+-------------------------------------------------------
+
+.. todo::
+
+   TODO: ``mprovider``, ``mextent`` and ``thingify``.
+
+
+From morphologies to points and segments
+----------------------------------------
+
+The :cpp:type:`morphology` class has the ``branch_segments`` method for
+returning a vector of :cpp:type:`msegment` objects that describe the geometry
+of that branch. However, determining the position in space of an
+:cpp:type:`mlocation`, for example, requires some assumptions about how to
+position points which fall inside a morphological segment.
+
+The :cpp:type:`place_pwlin` class takes a :cpp:type:`morphology` (and
+optionally an :cpp:type:`isometry`) and interprets it as describing a
+piecewise-linear object in space. It can then be queried to find the 3-d
+positions in space of points on the morphology and the extents in space of
+morphological sub-regions.
+
+Because the morphology need not be contiguous in space, a position query can
+potentially give more than one possible answer. Similarly, a description of a
+cable in terms of segments or partial segments in space may include multiple
+zero-length components as a result of such discontinuities.
+
+.. cpp:class:: place_pwlin
+
+   .. cpp:function:: place_pwlin(const morphology&, const isometry& = isometry())
+
+      Construct a piecewise linear placement of the morphology in space,
+      optionally applying the given isometry.
+
+   .. cpp:function:: mpoint at(mlocation) const
+
+      Return any single point corresponding to the given :cpp:class:`mlocation`
+      in the placement.
+
+   .. cpp:function:: std::vector<mpoint> all_at(mlocation) const
+
+      Return all points corresponding to the given :cpp:class:`mlocation` in
+      the placement.
+
+   .. cpp:function:: std::vector<msegment> segments(const mextent&) const
+
+      Return any minimal collection of segments and partial segments whose
+      union is coterminous with the given :cpp:class:`mextent` in the placement.
+
+   .. cpp:function:: std::vector<msegment> all_segments(const mextent&) const
+
+      Return the maximal set of segments and partial segments whose
+      union is coterminous with the given :cpp:class:`mextent` in the placement.
+
+Isometries
+^^^^^^^^^^
+
+The one cellular morphology may be used to represent multiple cable cells
+which are in principle sited in different locations and orientations.
+An explicit isometry allows the one morphology to be repositioned so as
+to answer location queries on such cells.
+
+An isometry consists of a rotation and a translation. Isometries can be
+composed; as interpreted by Arbor, translations are always regarded as
+being relative to the absolute, extrinsic co-ordinate system, while
+rotations are interpreted as *intrinsic rotations*: rotations are always
+applied with respect to the coordinate system carried with the object,
+not the absolute co-ordinate axes.
+
+.. cpp:class:: isometry
+
+   .. cpp:function:: isometry()
+
+      Construct an identity isometry.
+
+   .. cpp:function:: static isometry translate(double x, double y, double z)
+
+      Construct a translation (x, y, z) with respect to the extrinsic coordinate system.
+
+   .. cpp:function:: template <typename Point> static isometry translate(const Point& p)
+
+      Construct a translation (p.x, p.y, p.z) from an arbitrary object with the corresponding
+      public member variables.
+
+   .. cpp:function:: static isometry rotate(double theta, double x, double y, double z)
+
+      Construct a rotation of theta radians about the axis (x, y, z) with respect to the intrinsic coordinate system.
+
+   .. cpp:function:: template <typename Point> static isometry translate(double theta, const Point& p)
+
+      Construct a rotation of theta radians about the (p.x, p.y, p.z) from an arbitrary object with the corresponding
+      public member variables.
+
+   .. cpp:function:: template <typename Point> Point apply(Point p) const
+
+      The Point object is interpreted as a point in space given by public member variables x, y, and z.
+      The isometry is applied to the point (x, y, z), and a copy of ``p`` is returned with the new
+      coordinate values.
+
+.. cpp:function:: isometry operator*(const isometry& a, const isometry& b)
+
+      Compose two isometries to form a new isometry which applies the intrinsic rotation of *b*, and
+      then the intrinsic rotation of *a*, together with the translations of both *a* and *b*.
+
+
diff --git a/doc/python/morphology.rst b/doc/python/morphology.rst
index 65b83032238f5221996de501b8ed279600fca061..c4e20bc1d381443f25ac6b203f25bf1e54a9e9b5 100644
--- a/doc/python/morphology.rst
+++ b/doc/python/morphology.rst
@@ -394,3 +394,91 @@ Cell morphology
     :param str filename: the name of the SWC file.
     :param bool no_gaps: enforce that distance between soma center and branches attached to soma is the soma radius.
     :rtype: morphology
+
+.. py:class:: place_pwlin
+
+    A :class:`place_pwlin` object allows the querying of the 3-d location of locations and cables
+    in a morphology. Refer to the C++ documentation for :cpp:type:`place_pwlin` for more details.
+
+    .. py:function:: place_pwlin(morphology, isometry)
+    .. py:function:: place_pwlin(morphology)
+       :noindex:
+
+       Construct a piecewise linear placement of the morphology in space,
+       optionally applying the given isometry.
+
+    .. py:method:: at(loc: location) -> location
+
+       Return any single point corresponding to the :class:`location` ``loc``
+       in the placement.
+
+    .. py:method:: all_at(loc: location) -> list[location]
+
+       Return all points corresponding to the given :class:`location` ``loc``
+       the placement.
+
+    .. py:method:: segments(cables: list[cable]) -> list[segment]
+
+       Return any minimal collection of segments and partial segments whose
+       union is coterminous with the sub-region of the morphology covered by
+       the given cables in the placement.
+
+    .. py:method:: all_segments(cables: list[cable]) -> list[segment]
+
+       Return the maximal set of segments and partial segments whose
+       union is coterminous with the sub-region of the morphology covered by
+       the given cables in the placement.
+
+.. py:class:: isometry
+
+    Isometries represent rotations and translations in space, and can be used with
+    :class:`place_pwlin` to position a morphology in an arbitrary spatial location
+    and orientation. Refer to the C++ documentation for :cpp:type:`isometry` for
+    more details.
+
+    .. py::function:: isometry()
+
+       Construct an identity isometry.
+
+    .. py:method:: translate(x: float, y: float, z: float) -> isometry
+       :staticmethod:
+
+       Construct a translation (x, y, z) with respect to the extrinsic coordinate system.
+
+    .. py:method:: translate(displacement: Tuple[float, float, float]) -> isometry
+       :staticmethod:
+       :noindex:
+
+       Construct a translation from the elements of the given tuple.
+
+    .. py:method:: translate(displacement: mpoint) -> isometry
+       :staticmethod:
+       :noindex:
+
+       Construct a translation from the (x, y, z) components of the given :py:class:`mpoint`.
+
+    .. py:method:: rotate(theta: float, x: float, y: float, z: float) -> isometry
+       :staticmethod:
+
+       Construct a rotation of ``theta`` radians about the axis (x, y, z) with respect to the intrinsic coordinate system.
+
+    .. py:method:: rotate(theta: float, axiss: Tuple[float, float, float]) -> isometry
+       :staticmethod:
+       :noindex:
+
+       Construct a rotation of ``theta`` radians about the axis given by the ``axis`` tuple.
+
+    .. py:method:: __call__(point: mpoint) -> mpoint
+
+       Apply the isometry to given point.
+
+    .. py:method:: __call__(point: Tuple[float, float, float, ...]) -> Tuple[float, float, float, ...]
+       :noindex:
+
+       Apply the isometry to the first three components of the given tuple, interpreted as a point.
+
+    .. py:function:: __mul__(a: isometry, b: isometry) -> isometry
+
+       Compose the two isometries to form a new isometry that applies *b* and then applies *a*.
+       Note that rotations are composed as being with respect to the *intrinsic* coordinate system,
+       while translations are always taken to be with respect to the *extrinsic* absolute coordinate system.
diff --git a/doc/requirements.txt b/doc/requirements.txt
index c4e363475fc7c5aa3fadbe0113a1f914ccc12ce0..7eb5a6f21c5e3231c0a2204def1956eeac892c2c 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -1,2 +1,2 @@
-sphinx>=2.3.0
+sphinx>=2.4.0
 svgwrite
diff --git a/python/morphology.cpp b/python/morphology.cpp
index 9b89c0fca69021e8ab69e5899b34bdcbad6d6758..0412b3d0ec74cb7d76c775bf24878dbfbbbb9663 100644
--- a/python/morphology.cpp
+++ b/python/morphology.cpp
@@ -1,11 +1,13 @@
+#include <fstream>
+#include <tuple>
+
 #include <pybind11/operators.h>
 #include <pybind11/pybind11.h>
 #include <pybind11/pytypes.h>
 #include <pybind11/stl.h>
 
-#include <fstream>
-
 #include <arbor/morph/morphology.hpp>
+#include <arbor/morph/place_pwlin.hpp>
 #include <arbor/morph/primitives.hpp>
 #include <arbor/morph/segment_tree.hpp>
 
@@ -18,6 +20,10 @@ namespace py = pybind11;
 
 namespace pyarb {
 
+static inline bool cable_lt(const arb::mcable& a, const arb::mcable& b) {
+    return std::tuple(a.branch, a.prox_pos, a.dist_pos)<std::tuple(b.branch, b.prox_pos, b.dist_pos);
+}
+
 void check_trailing(std::istream& in, std::string fname) {
     if (!(in >> std::ws).eof()) {
         throw pyarb_error(util::pprintf("Trailing data found at end of file '{}'", fname));
@@ -60,26 +66,25 @@ void register_morphology(py::module& m) {
     // arb::mpoint
     py::class_<arb::mpoint> mpoint(m, "mpoint");
     mpoint
-        .def(py::init(
-                [](double x, double y, double z, double r) {
-                    return arb::mpoint{x,y,z,r};
-                }),
-                "x"_a, "y"_a, "z"_a, "radius"_a, "All values in μm.")
+        .def(py::init<double, double, double, double>(),
+             "x"_a, "y"_a, "z"_a, "radius"_a,
+             "Create an mpoint object from parameters x, y, z, and radius, specified in µm.")
         .def(py::init([](py::tuple t) {
                 if (py::len(t)!=4) throw std::runtime_error("tuple length != 4");
-                return arb::mpoint{t[0].cast<double>(), t[1].cast<double>(), t[2].cast<double>(), t[3].cast<double>()};
-            }))
+                return arb::mpoint{t[0].cast<double>(), t[1].cast<double>(), t[2].cast<double>(), t[3].cast<double>()}; }),
+             "Create an mpoint object from a tuple (x, y, z, radius), specified in µm.")
         .def_readonly("x", &arb::mpoint::x, "X coordinate [μm].")
         .def_readonly("y", &arb::mpoint::y, "Y coordinate [μm].")
         .def_readonly("z", &arb::mpoint::z, "Z coordinate [μm].")
         .def_readonly("radius", &arb::mpoint::radius,
             "Radius of cable at sample location centered at coordinates [μm].")
+        .def(py::self==py::self)
         .def("__str__",
             [](const arb::mpoint& p) {
                 return util::pprintf("<arbor.mpoint: x {}, y {}, z {}, radius {}>", p.x, p.y, p.z, p.radius);
             })
         .def("__repr__",
-            [](const arb::mpoint& p) {return util::pprintf("{}>", p);});
+            [](const arb::mpoint& p) {return util::pprintf("{}", p);});
 
     py::implicitly_convertible<py::tuple, arb::mpoint>();
 
@@ -112,6 +117,82 @@ void register_morphology(py::module& m) {
         .def("__str__", [](const arb::mcable& c) { return util::pprintf("{}", c); })
         .def("__repr__", [](const arb::mcable& c) { return util::pprintf("{}", c); });
 
+    // arb::isometry
+    py::class_<arb::isometry> isometry(m, "isometry");
+    isometry
+        .def(py::init<>(), "Construct a trivial isometry.")
+        .def("__call__", [](arb::isometry& iso, arb::mpoint& p) {
+                return iso.apply(p);
+            },
+            "Apply isometry to mpoint argument.")
+        .def("__call__", [](arb::isometry& iso, py::tuple t) {
+                int len = py::len(t);
+                if (len<3) throw std::runtime_error("tuple length < 3");
+
+                arb::mpoint p{t[0].cast<double>(), t[1].cast<double>(), t[2].cast<double>(), 0.};
+                p = iso.apply(p);
+
+                py::tuple result(len);
+                result[0] = p.x;
+                result[1] = p.y;
+                result[2] = p.z;
+                for (int i = 3; i<len; ++i) {
+                    result[i] = t[i];
+                }
+                return result;
+            },
+            "Apply isometry to first three components of tuple argument.")
+        .def(py::self*py::self)
+        .def_static("translate",
+            [](double x, double y, double z) { return arb::isometry::translate(x, y, z); },
+            "x"_a, "y"_a, "z"_a,
+            "Construct a translation isometry from displacements x, y, and z.")
+        .def_static("translate",
+            [](py::tuple t) {
+                if (py::len(t)!=3) throw std::runtime_error("tuple length != 3");
+                return arb::isometry::translate(t[0].cast<double>(), t[1].cast<double>(), t[2].cast<double>());
+            },
+            "Construct a translation isometry from the first three components of a tuple.")
+        .def_static("translate",
+            [](arb::mpoint p) { return arb::isometry::translate(p.x, p.y, p.z); },
+            "Construct a translation isometry from the x, y, and z components of an mpoint.")
+        .def_static("rotate",
+            [](double theta, double x, double y, double z) { return arb::isometry::rotate(theta, x, y, z); },
+            "theta"_a, "x"_a, "y"_a, "z"_a,
+            "Construct a rotation isometry of angle theta about the axis in direction (x, y, z).")
+        .def_static("rotate",
+            [](double theta, py::tuple t) {
+                if (py::len(t)!=3) throw std::runtime_error("tuple length != 3");
+                return arb::isometry::rotate(theta, t[0].cast<double>(), t[1].cast<double>(), t[2].cast<double>());
+            },
+            "theta"_a, "axis"_a,
+            "Construct a rotation isometry of angle theta about the given axis in the direction described by a tuple.");
+
+    // arb::place_pwlin
+    py::class_<arb::place_pwlin> place(m, "place_pwlin");
+    place
+        .def(py::init<const arb::morphology&, const arb::isometry&>(),
+            "morphology"_a, "isometry"_a=arb::isometry{},
+            "Construct a piecewise-linear placement object from the given morphology and optional isometry.")
+        .def("at", &arb::place_pwlin::at, "location"_a,
+            "Return an interpolated mpoint corresponding to the location argument.")
+        .def("all_at", &arb::place_pwlin::all_at, "location"_a,
+            "Return list of all possible interpolated mpoints corresponding to the location argument.")
+        .def("segments",
+            [](const arb::place_pwlin& self, std::vector<arb::mcable> cables) {
+                std::sort(cables.begin(), cables.end(), cable_lt);
+                return self.segments(cables);
+            },
+            "Return minimal list of full or partial msegments whose union is coterminous "
+            "with the extent of the given list of cables.")
+        .def("all_segments",
+            [](const arb::place_pwlin& self, std::vector<arb::mcable> cables) {
+                std::sort(cables.begin(), cables.end(), cable_lt);
+                return self.all_segments(cables);
+            },
+            "Return maximal list of non-overlapping full or partial msegments whose union is coterminous "
+            "with the extent of the given list of cables.");
+
     //
     // Higher-level data structures (segment_tree, morphology)
     //
diff --git a/python/test/unit/runner.py b/python/test/unit/runner.py
index 231b88b16ca7c6d041a00ac5252911960907bf35..b727c46647d251114cfef81d85c36d2703cf9cad 100644
--- a/python/test/unit/runner.py
+++ b/python/test/unit/runner.py
@@ -17,6 +17,7 @@ try:
     import test_tests
     import test_schedules
     import test_cable_probes
+    import test_morphology
     # add more if needed
 except ModuleNotFoundError:
     from test import options
@@ -26,6 +27,7 @@ except ModuleNotFoundError:
     from test.unit import test_identifiers
     from test.unit import test_schedules
     from test.unit import test_cable_probes
+    from test.unit import test_morphology
     # add more if needed
 
 test_modules = [\
@@ -34,7 +36,8 @@ test_modules = [\
     test_event_generators,\
     test_identifiers,\
     test_schedules,\
-    test_cable_probes\
+    test_cable_probes,\
+    test_morphology\
 ] # add more if needed
 
 def suite():
diff --git a/python/test/unit/test_morphology.py b/python/test/unit/test_morphology.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbc37da98a002a91149138890d6ce332fc4e85ea
--- /dev/null
+++ b/python/test/unit/test_morphology.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+#
+# test_morphology.py
+
+import unittest
+import arbor as A
+import numpy as N
+import math
+
+# to be able to run .py file from child directory
+import sys, os
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
+
+try:
+    import options
+except ModuleNotFoundError:
+    from test import options
+
+"""
+tests for morphology-related classes
+"""
+
+def as_matrix(iso):
+    trans = N.array(iso((0, 0, 0)))
+    return N.c_[N.array([iso(v) for v in [(1,0,0),(0,1,0),(0,0,1)]]).transpose()-N.c_[trans, trans, trans], trans]
+
+class PlacePwlin(unittest.TestCase):
+    def test_identity(self):
+        self.assertTrue(N.isclose(as_matrix(A.isometry()), N.eye(3, 4)).all())
+
+    def test_translation(self):
+        displacement=(4, 5, 6)
+        iso = A.isometry.translate(displacement)
+        expected = N.c_[N.eye(3), displacement]
+        self.assertTrue(N.isclose(as_matrix(iso), expected).all())
+
+    def test_rotation(self):
+        # 90 degrees about y axis.
+        iso = A.isometry.rotate(theta=math.pi/2, axis=(0, 1, 0))
+        expected = N.array([[0, 0, 1, 0],[0, 1, 0, 0],[-1, 0, 0, 0]])
+        self.assertTrue(N.isclose(as_matrix(iso), expected).all())
+
+    def test_compose(self):
+        # Translations are always extrinsic, rotations are intrinsic.
+        y90 = A.isometry.rotate(theta=math.pi/2, axis=(0, 1, 0))
+        z90 = A.isometry.rotate(theta=math.pi/2, axis=(0, 0, 1))
+        t123 = A.isometry.translate(1, 2, 3)
+        t456 = A.isometry.translate(4, 5, 6)
+        iso = t123*z90*t456*y90   # rot about y, then translate, then rot about z, then translate.
+        expected = N.array([[0, 0, 1, 5],[1, 0, 0, 7],[0, 1, 0, 9]])
+        self.assertTrue(N.isclose(as_matrix(iso), expected).all())
+
+    def test_mpoint(self):
+        # Translations can be built from mpoints, and isometry can act upon mpoints. Radius is ignored.
+        y90 = A.isometry.rotate(theta=math.pi/2, axis=(0, 1, 0))
+        z90 = A.isometry.rotate(theta=math.pi/2, axis=(0, 0, 1))
+        t123 = A.isometry.translate(A.mpoint(1, 2, 3, 20))
+        t456 = A.isometry.translate(A.mpoint(4, 5, 6, 30))
+        iso = t123*z90*t456*y90
+        expected = N.array([[0, 0, 1, 5],[1, 0, 0, 7],[0, 1, 0, 9]])
+        self.assertTrue(N.isclose(as_matrix(iso), expected).all())
+
+        q = iso(A.mpoint(2, 3, 4, 10))
+        q_arr = N.array((q.x, q.y, q.z, q.radius))
+        q_expected = N.array([4+5, 2+7, 3+9, 10])
+        self.assertTrue(N.isclose(q_arr, q_expected).all())
+
+    def test_place_pwlin_id(self):
+        # Single branch, discontiguous segments.
+        s0p = A.mpoint(0, 0, 0, 10)
+        s0d = A.mpoint(1, 0, 0, 10)
+        s1p = A.mpoint(3, 0, 0, 10)
+        s1d = A.mpoint(4, 0, 0, 10)
+
+        tree = A.segment_tree()
+        i = tree.append(A.mnpos, s0p, s0d, 1)
+        tree.append(i, s1p, s1d, 2)
+
+        m = A.morphology(tree)
+        place = A.place_pwlin(m)
+
+        L0 = place.at(A.location(0, 0))
+        L0s = place.all_at(A.location(0, 0))
+        self.assertEqual(s0p, L0)
+        self.assertEqual([s0p], L0s)
+
+        Lhalf = place.at(A.location(0, 0.5))
+        Lhalfs = place.all_at(A.location(0, 0.5))
+        self.assertTrue(s0d==Lhalf or s1p==Lhalf)
+        self.assertTrue([s0d, s1p]==Lhalfs)
+
+        Chalf = [(s.prox, s.dist) for s in place.segments([A.cable(0, 0., 0.5)])]
+        self.assertEqual([(s0p, s0d)], Chalf)
+
+        Chalf_all = [(s.prox, s.dist) for s in place.all_segments([A.cable(0, 0., 0.5)])]
+        self.assertEqual([(s0p, s0d), (s1p, s1p)], Chalf_all)
+
+    def test_place_pwlin_isometry(self):
+        # Single branch, discontiguous segments.
+        s0p = A.mpoint(0, 0, 0, 10)
+        s0d = A.mpoint(1, 0, 0, 10)
+        s1p = A.mpoint(3, 0, 0, 10)
+        s1d = A.mpoint(4, 0, 0, 10)
+
+        tree = A.segment_tree()
+        i = tree.append(A.mnpos, s0p, s0d, 1)
+        tree.append(i, s1p, s1d, 2)
+
+        m = A.morphology(tree)
+        iso = A.isometry.translate(2, 3, 4)
+        place = A.place_pwlin(m, iso)
+
+        x0p = iso(s0p)
+        x0d = iso(s0d)
+        x1p = iso(s1p)
+        x1d = iso(s1d)
+
+        L0 = place.at(A.location(0, 0))
+        L0s = place.all_at(A.location(0, 0))
+        self.assertEqual(x0p, L0)
+        self.assertEqual([x0p], L0s)
+
+        Lhalf = place.at(A.location(0, 0.5))
+        Lhalfs = place.all_at(A.location(0, 0.5))
+        self.assertTrue(x0d==Lhalf or x1p==Lhalf)
+        self.assertTrue([x0d, x1p]==Lhalfs)
+
+        Chalf = [(s.prox, s.dist) for s in place.segments([A.cable(0, 0., 0.5)])]
+        self.assertEqual([(x0p, x0d)], Chalf)
+
+        Chalf_all = [(s.prox, s.dist) for s in place.all_segments([A.cable(0, 0., 0.5)])]
+        self.assertEqual([(x0p, x0d), (x1p, x1p)], Chalf_all)
+
+def suite():
+    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
+    suite = unittest.makeSuite(PlacePwlin, ('test'))
+    return suite
+
+def run():
+    v = options.parse_arguments().verbosity
+    runner = unittest.TextTestRunner(verbosity = v)
+    runner.run(suite())
+
+if __name__ == "__main__":
+    run()