diff --git a/arbor/include/arbor/morph/label_dict.hpp b/arbor/include/arbor/morph/label_dict.hpp
index f7c43ec6bbf9868d66dc17359352e22c1481cc9b..7d909ed0c735ecb3e0d8b8dd9d0737322bc3d640 100644
--- a/arbor/include/arbor/morph/label_dict.hpp
+++ b/arbor/include/arbor/morph/label_dict.hpp
@@ -24,7 +24,7 @@ public:
     // construct a label dict with SWC tags predefined
     label_dict& add_swc_tags();
 
-    void import(const label_dict& other, const std::string& prefix = "");
+    label_dict& extend(const label_dict& other, const std::string& prefix = "");
 
     label_dict& set(const std::string& name, locset ls);
     label_dict& set(const std::string& name, region reg);
diff --git a/arbor/morph/label_dict.cpp b/arbor/morph/label_dict.cpp
index ba0c4d83cd905c87d8ea5603c901159b22854720..11ac3e5a18a6a4bc1332da17e00ab534b82f4d05 100644
--- a/arbor/morph/label_dict.cpp
+++ b/arbor/morph/label_dict.cpp
@@ -45,7 +45,7 @@ label_dict& label_dict::set(const std::string& name, arb::iexpr e) {
     return *this;
 }
 
-void label_dict::import(const label_dict& other, const std::string& prefix) {
+label_dict& label_dict::extend(const label_dict& other, const std::string& prefix) {
     for (const auto& entry: other.locsets()) {
         set(prefix+entry.first, entry.second);
     }
@@ -55,6 +55,7 @@ void label_dict::import(const label_dict& other, const std::string& prefix) {
     for (const auto& entry: other.iexpressions()) {
         set(prefix+entry.first, entry.second);
     }
+    return *this;
 }
 
 std::optional<region> label_dict::region(const std::string& name) const {
diff --git a/arborio/include/arborio/loaded_morphology.hpp b/arborio/include/arborio/loaded_morphology.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..84dbc41806db177c1860d8f718f36b763fd53fdc
--- /dev/null
+++ b/arborio/include/arborio/loaded_morphology.hpp
@@ -0,0 +1,76 @@
+#pragma once
+
+#include <variant>
+
+#include <arborio/export.hpp>
+
+#include <arbor/morph/label_dict.hpp>
+#include <arbor/morph/morphology.hpp>
+#include <arbor/morph/segment_tree.hpp>
+
+namespace arborio {
+
+struct ARB_ARBORIO_API swc_metadata {};
+
+struct ARB_ARBORIO_API asc_color {
+    uint8_t r = 0;
+    uint8_t g = 0;
+    uint8_t b = 0;
+};
+
+struct ARB_ARBORIO_API asc_spine {
+    std::string name;
+    arb::mpoint location;
+};
+
+enum ARB_ARBORIO_API asc_marker { dot, circle, cross, none };
+
+struct ARB_ARBORIO_API asc_marker_set {
+    asc_color color;
+    asc_marker marker = asc_marker::none;
+    std::string name;
+    std::vector<arb::mpoint> locations;
+};
+
+struct ARB_ARBORIO_API asc_metadata {
+    std::vector<asc_marker_set> markers;
+    std::vector<asc_spine> spines;
+};
+
+// Bundle some detailed metadata for neuroml ingestion.
+struct ARB_SYMBOL_VISIBLE nml_metadata {
+    // Cell id, or empty if morphology was taken from a top-level <morphology> element.
+    std::optional<std::string> cell_id;
+
+    // Morphology id.
+    std::string id;
+
+    // One region expression for each segment id.
+    arb::label_dict segments;
+
+    // One region expression for each name applied to one or more segments.
+    arb::label_dict named_segments;
+
+    // One region expression for each segmentGroup id.
+    arb::label_dict groups;
+
+    // Map from segmentGroup ids to their corresponding segment ids.
+    std::unordered_map<std::string, std::vector<unsigned long long>> group_segments;
+};
+
+// Interface for ingesting morphology data
+struct ARB_ARBORIO_API loaded_morphology {
+    // Raw segment tree, identical to morphology.
+    arb::segment_tree segment_tree;
+
+    // Morphology constructed from description.
+    arb::morphology morphology;
+
+    // Regions and locsets defined in the description.
+    arb::label_dict labels;
+
+    // Loader specific metadata
+    std::variant<swc_metadata, asc_metadata, nml_metadata> metadata;
+};
+
+}
diff --git a/arborio/include/arborio/neurolucida.hpp b/arborio/include/arborio/neurolucida.hpp
index 260459f883c37a252eba8d8d3746fb31d6407f6a..8019fcf592e8a00046c9e8dfa41ff54810dba781 100644
--- a/arborio/include/arborio/neurolucida.hpp
+++ b/arborio/include/arborio/neurolucida.hpp
@@ -6,8 +6,8 @@
 #include <filesystem>
 
 #include <arbor/arbexcept.hpp>
-#include <arbor/morph/label_dict.hpp>
-#include <arbor/morph/morphology.hpp>
+
+#include <arborio/loaded_morphology.hpp>
 #include <arborio/export.hpp>
 
 namespace arborio {
@@ -33,23 +33,10 @@ struct ARB_SYMBOL_VISIBLE asc_unsupported: asc_exception {
     std::string message;
 };
 
-struct asc_morphology {
-    // Raw segment tree from ASC, identical to morphology.
-    arb::segment_tree segment_tree;
-
-    // Morphology constructed from asc description.
-    arb::morphology morphology;
-
-    // Regions and locsets defined in the asc description.
-    arb::label_dict labels;
-};
-
 // Perform the parsing of the input as a string.
-ARB_ARBORIO_API asc_morphology parse_asc_string(const char* input);
-ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input);
+ARB_ARBORIO_API loaded_morphology parse_asc_string(const char* input);
 
 // Load asc morphology from file with name filename.
-ARB_ARBORIO_API asc_morphology load_asc(const std::filesystem::path& filename);
-ARB_ARBORIO_API arb::segment_tree load_asc_raw(const std::filesystem::path&filename);
+ARB_ARBORIO_API loaded_morphology load_asc(const std::filesystem::path& filename);
 
 } // namespace arborio
diff --git a/arborio/include/arborio/neuroml.hpp b/arborio/include/arborio/neuroml.hpp
index 7d889581b4c948fd25fe141699a84e9790f97301..a17431fccb3a9e7cc2743583ee4a08146453b8ec 100644
--- a/arborio/include/arborio/neuroml.hpp
+++ b/arborio/include/arborio/neuroml.hpp
@@ -8,6 +8,7 @@
 #include <unordered_map>
 #include <vector>
 
+#include <arborio/loaded_morphology.hpp>
 #include <arbor/morph/label_dict.hpp>
 #include <arbor/morph/morphology.hpp>
 #include <arborio/export.hpp>
@@ -69,29 +70,6 @@ struct ARB_SYMBOL_VISIBLE nml_cyclic_dependency: neuroml_exception {
 // Note: segment id values are interpreted as unsigned long long values;
 // parsing larger segment ids will throw an exception.
 
-struct nml_morphology_data {
-    // Cell id, or empty if morphology was taken from a top-level <morphology> element.
-    std::optional<std::string> cell_id;
-
-    // Morphology id.
-    std::string id;
-
-    // Morphology constructed from a single NeuroML <morphology> element.
-    arb::morphology morphology;
-
-    // One region expression for each segment id.
-    arb::label_dict segments;
-
-    // One region expression for each name applied to one or more segments.
-    arb::label_dict named_segments;
-
-    // One region expression for each segmentGroup id.
-    arb::label_dict groups;
-
-    // Map from segmentGroup ids to their corresponding segment ids.
-    std::unordered_map<std::string, std::vector<unsigned long long>> group_segments;
-};
-
 // Represent NeuroML data determined by provided string.
 
 struct ARB_ARBORIO_API neuroml_impl;
@@ -126,8 +104,8 @@ struct ARB_ARBORIO_API neuroml {
     // Parse and retrieve top-level morphology or morphology associated with a cell.
     // Return nullopt if not found.
 
-    std::optional<nml_morphology_data> morphology(const std::string& morph_id, enum neuroml_options::values = neuroml_options::none) const;
-    std::optional<nml_morphology_data> cell_morphology(const std::string& cell_id, enum neuroml_options::values = neuroml_options::none) const;
+    std::optional<loaded_morphology> morphology(const std::string& morph_id, enum neuroml_options::values = neuroml_options::none) const;
+    std::optional<loaded_morphology> cell_morphology(const std::string& cell_id, enum neuroml_options::values = neuroml_options::none) const;
 
     ~neuroml();
 
diff --git a/arborio/include/arborio/swcio.hpp b/arborio/include/arborio/swcio.hpp
index 38b59e0c5ae26fbb90595c744d2ee19de41e5f44..3d91e61f7a8eb1c678f19e28480b12072b1937bd 100644
--- a/arborio/include/arborio/swcio.hpp
+++ b/arborio/include/arborio/swcio.hpp
@@ -3,9 +3,11 @@
 #include <iostream>
 #include <string>
 #include <vector>
+#include <filesystem>
 
 #include <arbor/arbexcept.hpp>
-#include <arbor/morph/morphology.hpp>
+
+#include <arborio/loaded_morphology.hpp>
 #include <arborio/export.hpp>
 
 namespace arborio {
@@ -114,7 +116,6 @@ public:
 // conditions above are encountered.
 //
 // SWC records are returned in id order.
-
 ARB_ARBORIO_API swc_data parse_swc(std::istream&);
 ARB_ARBORIO_API swc_data parse_swc(const std::string&);
 
@@ -126,17 +127,15 @@ ARB_ARBORIO_API swc_data parse_swc(const std::string&);
 // one segment for each SWC record after the first: this record defines the tag
 // and distal point of the segment, while the proximal point is taken from the
 // parent record.
-
-ARB_ARBORIO_API arb::morphology load_swc_arbor(const swc_data& data);
-ARB_ARBORIO_API arb::segment_tree load_swc_arbor_raw(const swc_data& data);
+ARB_ARBORIO_API loaded_morphology load_swc_arbor(const swc_data& data);
+ARB_ARBORIO_API loaded_morphology load_swc_arbor(const std::filesystem::path& fn);
 
 // As above, will convert a valid, ordered sequence of SWC records into a morphology
 //
 // Note that 'one-point soma' SWC files are supported here
 //
 // Complies inferred SWC rules from NEURON, explicitly listed in the docs.
-
-ARB_ARBORIO_API arb::morphology load_swc_neuron(const swc_data& data);
-ARB_ARBORIO_API arb::segment_tree load_swc_neuron_raw(const swc_data& data);
+ARB_ARBORIO_API loaded_morphology load_swc_neuron(const swc_data& data);
+ARB_ARBORIO_API loaded_morphology load_swc_neuron(const std::filesystem::path& fn);
 
 } // namespace arborio
diff --git a/arborio/neurolucida.cpp b/arborio/neurolucida.cpp
index 12910e88a2c68a75905e008b201afd3e7c19bceb..96729037786df280d9b0640028e78abef8abd8b3 100644
--- a/arborio/neurolucida.cpp
+++ b/arborio/neurolucida.cpp
@@ -164,11 +164,6 @@ bool is_marker_symbol(const asc::token& t) {
 // Parse a color expression, which have been observed in the wild in two forms:
 //  (Color Red)                 ; labeled
 //  (Color RGB (152, 251, 152)) ; RGB literal
-struct asc_color {
-    uint8_t r = 0;
-    uint8_t g = 0;
-    uint8_t b = 0;
-};
 
 [[maybe_unused]]
 std::ostream& operator<<(std::ostream& o, const asc_color& c) {
@@ -307,16 +302,27 @@ parse_hopefully<arb::mpoint> parse_point(asc::lexer& L) {
 
 #define PARSE_POINT(L, X) if (auto rval__ = parse_point(L)) X=*rval__; else return FORWARD_PARSE_ERROR(rval__.error());
 
-parse_hopefully<arb::mpoint> parse_spine(asc::lexer& L) {
+parse_hopefully<asc_spine> parse_spine(asc::lexer& L) {
+    // check and consume opening <(
     EXPECT_TOKEN(L, tok::lt);
-    auto& t = L.current();
-    while (t.kind!=tok::gt && t.kind!=tok::error && t.kind!=tok::eof) {
-        L.next();
-    }
-    //if (t.kind!=error && t.kind!=eof)
+    EXPECT_TOKEN(L, tok::lparen);
+
+    arb::mpoint p;
+    PARSE_DOUBLE(L, p.x);
+    PARSE_DOUBLE(L, p.y);
+    PARSE_DOUBLE(L, p.z);
+    double diameter;
+    PARSE_DOUBLE(L, diameter);
+    p.radius = diameter/2.0;
+    // Now eat the label
+    std::string l = L.current().spelling;
+    L.next();
+
+    // check and consume closing )>
+    EXPECT_TOKEN(L, tok::rparen);
     EXPECT_TOKEN(L, tok::gt);
 
-    return arb::mpoint{};
+    return asc_spine{l, p};
 }
 
 #define PARSE_SPINE(L, X) if (auto rval__ = parse_spine(L)) X=std::move(*rval__); else return FORWARD_PARSE_ERROR(rval__.error());
@@ -342,30 +348,33 @@ parse_hopefully<std::string> parse_name(asc::lexer& L) {
 
 #define PARSE_NAME(L, X) {if (auto rval__ = parse_name(L)) X=*rval__; else return FORWARD_PARSE_ERROR(rval__.error());}
 
-struct marker_set {
-    asc_color color;
-    std::string name;
-    std::vector<arb::mpoint> locations;
-};
-
 [[maybe_unused]]
-std::ostream& operator<<(std::ostream& o, const marker_set& ms) {
+std::ostream& operator<<(std::ostream& o, const asc_marker_set& ms) {
     o << "(marker-set \"" << ms.name << "\" " << ms.color;
     for (auto& l: ms.locations) o << " " << l;
     return o << ")";
 
 }
 
-parse_hopefully<marker_set> parse_markers(asc::lexer& L) {
+parse_hopefully<asc_marker_set> parse_markers(asc::lexer& L) {
     EXPECT_TOKEN(L, tok::lparen);
 
-    marker_set markers;
+    asc_marker_set markers;
 
     // parse marker kind keyword
     auto t = L.current();
     if (!is_marker_symbol(t)) {
         return unexpected(PARSE_ERROR("expected a valid marker type", t.loc));
     }
+    if (t.spelling == "Dot") {
+        markers.marker = asc_marker::dot;
+    } else if (t.spelling == "Cross") {
+        markers.marker = asc_marker::cross;
+    } else if (t.spelling == "Circle") {
+        markers.marker = asc_marker::circle;
+    } else {
+        // impossible
+    }
     L.next();
     while (L.current().kind==tok::lparen) {
         auto n = L.peek();
@@ -392,6 +401,8 @@ parse_hopefully<marker_set> parse_markers(asc::lexer& L) {
 struct branch {
     std::vector<arb::mpoint> samples;
     std::vector<branch> children;
+    std::vector<asc_marker_set> markers;
+    std::vector<asc_spine> spines;
 };
 
 std::size_t num_samples(const branch& b) {
@@ -444,16 +455,15 @@ parse_hopefully<branch> parse_branch(asc::lexer& L) {
         }
         // A marker statement is always of the form ( MARKER_TYPE ...)
         else if (t.kind==tok::lparen && is_marker_symbol(p)) {
-            marker_set markers;
+            asc_marker_set markers;
             PARSE_MARKER(L, markers);
-            // Parse the markers, but don't record information about them.
-            // These could be grouped into locset by name and added to the label dictionary.
+            B.markers.push_back(markers);
         }
         // Spines are marked by a "less than", i.e. "<", symbol.
         else if (t.kind==tok::lt) {
-            arb::mpoint spine;
+            asc_spine spine;
             PARSE_SPINE(L, spine);
-            // parse the spine, but don't record the location.
+            B.spines.push_back(spine);
         }
         // Test for a symbol that indicates a terminal.
         else if (is_branch_end_symbol(t)) {
@@ -610,10 +620,16 @@ parse_hopefully<sub_tree> parse_sub_tree(asc::lexer& L) {
 
 
 // Perform the parsing of the input as a string.
-ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input) {
+ARB_ARBORIO_API std::tuple<arb::segment_tree,
+                           std::vector<asc_marker_set>,
+                           std::vector<asc_spine>>
+parse_asc_string_raw(const char* input) {
     asc::lexer lexer(input);
 
     std::vector<sub_tree> sub_trees;
+    std::vector<asc_marker_set> markers;
+    std::vector<asc_spine> spines;
+
 
     // Iterate over high-level pseudo-s-expressions in the file.
     // This pass simply parses the contents of the file, to be interpretted
@@ -695,6 +711,7 @@ ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input) {
     arb::mpoint soma_0, soma_1, soma_2;
     if (soma_count==1u) {
         const auto& st = sub_trees[soma_contours.front()];
+        // NOTE No children allowed!?
         const auto& samples = st.root.samples;
         if (samples.size()==1u) {
             // The soma is described as a sphere with a single sample.
@@ -709,10 +726,13 @@ ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input) {
             soma_0.radius = std::accumulate(samples.begin(), samples.end(), 0.,
                     [&soma_0](double a, auto& c) {return a+arb::distance(c, soma_0);}) / ns;
         }
+
         soma_1 = {soma_0.x, soma_0.y-soma_0.radius, soma_0.z, soma_0.radius};
         soma_2 = {soma_0.x, soma_0.y+soma_0.radius, soma_0.z, soma_0.radius};
         stree.append(arb::mnpos, soma_0, soma_1, 1);
         stree.append(arb::mnpos, soma_0, soma_2, 1);
+        spines.insert(spines.end(), st.root.spines.begin(), st.root.spines.end());
+        markers.insert(markers.end(), st.root.markers.begin(), st.root.markers.end());
     }
 
     // Append the dend, axon and apical dendrites.
@@ -742,6 +762,9 @@ ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input) {
             auto prox_sample = head.sample;
 
             if (!branch.samples.empty()) { // Skip empty branches, which are permitted
+                spines.insert(spines.end(), branch.spines.begin(), branch.spines.end());
+                markers.insert(markers.end(), branch.markers.begin(), branch.markers.end());
+
                 auto it = branch.samples.begin();
                 // Don't connect the first sample to the distal end of the parent
                 // branch if the parent is the soma center.
@@ -768,13 +791,13 @@ ARB_ARBORIO_API arb::segment_tree parse_asc_string_raw(const char* input) {
         }
     }
 
-    return stree;
+    return {stree, markers, spines};
 }
 
 
-ARB_ARBORIO_API asc_morphology parse_asc_string(const char* input) {
+ARB_ARBORIO_API loaded_morphology parse_asc_string(const char* input) {
     // Parse segment tree
-    arb::segment_tree stree = parse_asc_string_raw(input);
+    const auto& [stree, markers, spines] = parse_asc_string_raw(input);
 
     // Construct the morphology.
     arb::morphology morphology(stree);
@@ -786,7 +809,7 @@ ARB_ARBORIO_API asc_morphology parse_asc_string(const char* input) {
     labels.set("dend", arb::reg::tagged(3));
     labels.set("apic", arb::reg::tagged(4));
 
-    return {stree, std::move(morphology), std::move(labels)};
+    return {stree, std::move(morphology), std::move(labels), asc_metadata{markers, spines}};
 }
 
 
@@ -809,17 +832,8 @@ inline std::string read_file(const std::filesystem::path& filename) {
 }
 
 
-ARB_ARBORIO_API asc_morphology load_asc(const std::filesystem::path& filename) {
+ARB_ARBORIO_API loaded_morphology load_asc(const std::filesystem::path& filename) {
     std::string fstr = read_file(filename);
     return parse_asc_string(fstr.c_str());
 }
-
-
-ARB_ARBORIO_API arb::segment_tree load_asc_raw(const std::filesystem::path& filename) {
-    std::string fstr = read_file(filename);
-    return parse_asc_string_raw(fstr.c_str());
-}
-
-
 } // namespace arborio
-
diff --git a/arborio/neuroml.cpp b/arborio/neuroml.cpp
index 554b7a6db494c5f489554f28f6a8a13175bd49a0..fdc7a1914f96e083120ac7ae76957aefcdd4fe19 100644
--- a/arborio/neuroml.cpp
+++ b/arborio/neuroml.cpp
@@ -78,7 +78,7 @@ std::vector<std::string> neuroml::morphology_ids() const {
     return result;
 }
 
-optional<nml_morphology_data> neuroml::morphology(const std::string& morph_id, enum neuroml_options::values options) const {
+optional<loaded_morphology> neuroml::morphology(const std::string& morph_id, enum neuroml_options::values options) const {
     auto id = xpath_escape(morph_id);
     auto query = "//neuroml/morphology[@id=" + id + "]";
     auto match = impl_->doc.select_node(query.data()).node();
@@ -86,13 +86,13 @@ optional<nml_morphology_data> neuroml::morphology(const std::string& morph_id, e
     return nml_parse_morphology_element(match, options);
 }
 
-optional<nml_morphology_data> neuroml::cell_morphology(const std::string& cell_id, enum neuroml_options::values options) const {
+optional<loaded_morphology> neuroml::cell_morphology(const std::string& cell_id, enum neuroml_options::values options) const {
     auto id =  "//neuroml/cell[@id=" + xpath_escape(cell_id) + "]";
     auto query = "(//neuroml/morphology[@id=string((" + id + "/@morphology)[1])] | " + id + "/morphology)[1]";
     auto match = impl_->doc.select_node(query.data()).node();
     if (match.empty()) return nullopt;
-    nml_morphology_data M = nml_parse_morphology_element(match, options);
-    M.cell_id = cell_id;
+    auto M = nml_parse_morphology_element(match, options);
+    std::get<nml_metadata>(M.metadata).cell_id = cell_id;
     return M;
 }
 
diff --git a/arborio/nml_parse_morphology.cpp b/arborio/nml_parse_morphology.cpp
index 8bcfcf1682a5edf96767f26959d4620c80a954a0..f6731c67ee36469ec2034737ae4dc2c3d9021ae0 100644
--- a/arborio/nml_parse_morphology.cpp
+++ b/arborio/nml_parse_morphology.cpp
@@ -20,7 +20,6 @@
 #include "xml.hpp"
 
 using std::optional;
-using arb::region;
 using arb::util::expected;
 using arb::util::unexpected;
 
@@ -398,11 +397,13 @@ static arb::stitched_morphology construct_morphology(const neuroml_segment_tree&
     return arb::stitched_morphology(std::move(builder));
 }
 
-nml_morphology_data nml_parse_morphology_element(const xml_node& morph,
-                                                 enum neuroml_options::values options) {
+loaded_morphology nml_parse_morphology_element(const xml_node& morph,
+                                               enum neuroml_options::values options) {
     using namespace neuroml_options;
-    nml_morphology_data M;
-    M.id = get_attr<std::string>(morph, "id");
+    loaded_morphology M;
+    M.metadata = nml_metadata{};
+    auto& L = std::get<nml_metadata>(M.metadata);
+    L.id = get_attr<std::string>(morph, "id");
 
     std::vector<neuroml_segment> segments;
 
@@ -512,13 +513,14 @@ nml_morphology_data nml_parse_morphology_element(const xml_node& morph,
         groups.push_back(std::move(group));
     }
 
-    M.group_segments = evaluate_segment_groups(std::move(groups), segtree);
+    L.group_segments = evaluate_segment_groups(std::move(groups), segtree);
 
     // Build morphology and label dictionaries:
 
     arb::stitched_morphology stitched = construct_morphology(segtree);
     M.morphology = stitched.morphology();
-    M.segments = stitched.labels();
+    M.segment_tree = M.morphology.to_segment_tree();
+    L.segments = stitched.labels();
 
     std::unordered_multimap<std::string, non_negative> name_to_ids;
     std::unordered_set<std::string> names;
@@ -534,19 +536,22 @@ nml_morphology_data nml_parse_morphology_element(const xml_node& morph,
         arb::region r;
         auto ids = name_to_ids.equal_range(name);
         for (auto i = ids.first; i!=ids.second; ++i) {
-            r = join(std::move(r), M.segments.regions().at(std::to_string(i->second)));
+            r = join(std::move(r), L.segments.regions().at(std::to_string(i->second)));
         }
-        M.named_segments.set(name, std::move(r));
+        L.named_segments.set(name, std::move(r));
     }
 
-    for (const auto& [group_id, segment_ids]: M.group_segments) {
+    for (const auto& [group_id, segment_ids]: L.group_segments) {
         arb::region r;
         for (auto id: segment_ids) {
-            r = join(std::move(r), M.segments.regions().at(std::to_string(id)));
+            r = join(std::move(r), L.segments.regions().at(std::to_string(id)));
         }
-        M.groups.set(group_id, std::move(r));
+        L.groups.set(group_id, std::move(r));
     }
 
+    M.labels.extend(L.segments);
+    M.labels.extend(L.named_segments);
+    M.labels.extend(L.groups);
     return M;
 }
 
diff --git a/arborio/nml_parse_morphology.hpp b/arborio/nml_parse_morphology.hpp
index eb185604cd767b638133d62cc4dcce3dd691bf84..3d697a3cb2253e77b5f6a60bd800444bafecda23 100644
--- a/arborio/nml_parse_morphology.hpp
+++ b/arborio/nml_parse_morphology.hpp
@@ -1,11 +1,12 @@
 #pragma once
 
 #include <arborio/neuroml.hpp>
+#include <arborio/loaded_morphology.hpp>
 
 #include <pugixml.hpp>
 
 namespace arborio {
 
-nml_morphology_data nml_parse_morphology_element(const pugi::xml_node& morph, enum neuroml_options::values);
+loaded_morphology nml_parse_morphology_element(const pugi::xml_node& morph, enum neuroml_options::values);
 
 } // namespace arborio
diff --git a/arborio/swcio.cpp b/arborio/swcio.cpp
index f7ae9e79a8ff8dacd6f929a3825a449f25e01ab2..5cdfab8c224a85d735e8fc7cc336285dc471e678 100644
--- a/arborio/swcio.cpp
+++ b/arborio/swcio.cpp
@@ -6,11 +6,13 @@
 #include <set>
 #include <string>
 #include <sstream>
+#include <fstream>
 #include <unordered_map>
 #include <unordered_set>
 #include <vector>
 
 #include <arbor/morph/segment_tree.hpp>
+#include <arbor/morph/label_dict.hpp>
 
 #include "arbor/morph/primitives.hpp"
 
@@ -160,7 +162,7 @@ ARB_ARBORIO_API swc_data parse_swc(const std::string& text) {
     return parse_swc(is);
 }
 
-ARB_ARBORIO_API arb::segment_tree load_swc_arbor_raw(const swc_data& data) {
+arb::segment_tree load_swc_arbor_raw(const swc_data& data) {
     const auto& records = data.records();
 
     if (records.empty())  return {};
@@ -205,7 +207,7 @@ ARB_ARBORIO_API arb::segment_tree load_swc_arbor_raw(const swc_data& data) {
     return tree;
 }
 
-ARB_ARBORIO_API arb::segment_tree load_swc_neuron_raw(const swc_data& data) {
+arb::segment_tree load_swc_neuron_raw(const swc_data& data) {
     constexpr int soma_tag = 1;
 
     const auto n_samples = data.records().size();
@@ -324,8 +326,29 @@ ARB_ARBORIO_API arb::segment_tree load_swc_neuron_raw(const swc_data& data) {
     return tree;
 }
 
-ARB_ARBORIO_API arb::morphology load_swc_neuron(const swc_data& data) { return {load_swc_neuron_raw(data)}; }
-ARB_ARBORIO_API arb::morphology load_swc_arbor(const swc_data& data) { return {load_swc_arbor_raw(data)}; }
+ARB_ARBORIO_API loaded_morphology load_swc_neuron(const swc_data& data) {
+    auto raw = load_swc_neuron_raw(data);
+    arb::label_dict ld; ld.add_swc_tags();
+    return {raw, {raw}, ld, swc_metadata{}};
+}
+
+ARB_ARBORIO_API loaded_morphology load_swc_arbor(const swc_data& data) {
+    auto raw = load_swc_arbor_raw(data);
+    arb::label_dict ld; ld.add_swc_tags();
+    return {raw, {raw}, ld, swc_metadata{}};
+}
+
+ARB_ARBORIO_API loaded_morphology load_swc_arbor(const std::filesystem::path& path) {
+    std::ifstream fd(path);
+    if (!fd) throw arb::file_not_found_error("unable to open SWC file: "+path.string());
+    return load_swc_arbor(parse_swc(fd));
+}
+
+ARB_ARBORIO_API loaded_morphology load_swc_neuron(const std::filesystem::path& path) {
+    std::ifstream fd(path);
+    if (!fd) throw arb::file_not_found_error("unable to open SWC file: "+path.string());
+    return load_swc_neuron(parse_swc(fd));
+}
 
 } // namespace arborio
 
diff --git a/doc/cpp/morphology.rst b/doc/cpp/morphology.rst
index 178878e9304b3708801cbb60b4f8c2d6365394d9..cb353ccf76fd098e786bc487265f1ca6b8bfc072 100644
--- a/doc/cpp/morphology.rst
+++ b/doc/cpp/morphology.rst
@@ -475,7 +475,28 @@ The following classes and functions allow the user to inspect the CVs of a cell
 Supported morphology formats
 ============================
 
-Arbor supports morphologies described using the SWC file format and the NeuroML file format.
+Arbor supports morphologies described using SWC, Neurolucida ASC, and the NeuroML file formats.
+The ingestion of these formats is described below, but each returns a structure
+
+
+.. cpp:class:: loaded_morphology
+
+   .. cpp:member:: arb::segment_tree segment_tree
+
+    Raw segment tree, identical to morphology.
+
+   .. cpp:member:: arb::morphology morphology
+
+    Morphology constructed from description.
+
+
+   .. cpp:member:: arb::label_dict labels
+
+    Regions and locsets defined in the description.
+
+   .. cpp:member:: std::variant<swc_metadata, asc_metadata, nml_metadata> metadata
+
+    Loader specific metadata, see below in the individual sections.
 
 .. _cppswc:
 
@@ -549,6 +570,17 @@ basic checks performed on them. The :cpp:type:`swc_data` object can then be used
    Returns a :cpp:type:`morphology` constructed according to NEURON's
    :ref:`SWC specifications <formatswc-neuron>`.
 
+.. cpp:function:: morphology load_swc_arbor(const std::filesystem::path& data)
+
+   Returns a :cpp:type:`morphology` constructed according to Arbor's
+   :ref:`SWC specifications <formatswc-arbor>`.
+
+.. cpp:function:: morphology load_swc_neuron(const std::filesystem::path& data)
+
+   Returns a :cpp:type:`morphology` constructed according to NEURON's
+   :ref:`SWC specifications <formatswc-neuron>`.
+
+
 .. _cppasc:
 
 Neurolucida ASCII
@@ -557,21 +589,40 @@ Neurolucida ASCII
 Arbor supports reading morphologies described using the
 :ref:`Neurolucida ASCII file format <formatasc>`.
 
-The :cpp:func:`parse_asc()` function is used to parse the SWC file and generate a :cpp:type:`asc_morphology` object:
-a simple struct with two members representing the morphology and a label dictionary with labeled
-regions and locations.
+The :cpp:func:`parse_asc()` function is used to parse the SWC file and generate a :cpp:type:`loaded_morphology` object:
 
-.. cpp:class:: asc_morphology
+.. cpp:function:: loaded_morphology load_asc(const std::filesystem::path& filename)
 
-   .. cpp:member:: arb::morphology morphology
+   Parse a Neurolucida ASCII file.
+   Throws an exception if there is an error parsing the file.
 
-   .. cpp:member:: arb::label_dict labels
+.. cpp:class:: asc_metadata
 
-.. cpp:function:: asc_morphology load_asc(const std::filesystem::path& filename)
+   .. cpp:member:: std::vector<asc_spine> spines
 
-   Parse a Neurolucida ASCII file.
-   Throws an exception if there is an error parsing the file.
+   A list of spines annotated in the ``.asc`` file.
+
+   .. cpp:member:: std::vector<asc_marker_set> spines
+
+   A list of marker set annotated in the ``.asc`` file.
 
+.. cpp:class:: asc_spine
+
+    .. cpp:member:: std::string name
+
+    .. cpp:member:: arb::mpoint location
+
+.. cpp:class:: asc_marker_set
+    .. cpp:member:: asc_color color
+
+    .. cpp:member:: asc_marker marker = asc_marker::none
+
+    .. cpp:member:: std::string name
+
+    .. cpp:member:: std::vector<arb::mpoint> locations
+
+where ``asc_marker`` is an enum of ``dot``, ``circle``, ``cross``, or ``none``,
+and ``asc_color`` an RGB triple.
 
 .. _cppneuroml:
 
@@ -614,12 +665,12 @@ namespace.
 
    Return the id of each top-level ``<morphology>`` element defined in the NeuroML document.
 
-   .. cpp:function:: std::optional<nml_morphology_data> morphology(const std::string&, enum neuroml_options::value = neuroml_options::none) const
+   .. cpp:function:: std::optional<loaded_morphology> morphology(const std::string&, enum neuroml_options::value = neuroml_options::none) const
 
    Return a representation of the top-level morphology with the supplied identifier, or
    ``std::nullopt`` if no such morphology could be found.
 
-   .. cpp:function:: std::optional<nml_morphology_data> cell_morphology(const std::string&, enum neuroml_options::value = neuroml_options::none) const
+   .. cpp:function:: std::optional<loaded_morphology> cell_morphology(const std::string&, enum neuroml_options::value = neuroml_options::none) const
 
    Return a representation of the morphology associated with the cell with the supplied identifier,
    or ``std::nullopt`` if the cell or its morphology could not be found.
@@ -643,7 +694,7 @@ label dictionaries for regions corresponding to its segments and segment groups
 and id, and a map providing the explicit list of segments contained within each defined
 segment group.
 
-.. cpp:class:: nml_morphology_data
+.. cpp:class:: nml_metadata
 
    .. cpp:member:: std::optional<std::string> cell_id
 
@@ -653,10 +704,6 @@ segment group.
 
    The id attribute of the morphology.
 
-   .. cpp:member:: arb::morphology morphology
-
-   The corresponding Arbor morphology.
-
    .. cpp:member:: arb::label_dict segments
 
    A label dictionary with a region entry for each segment, keyed by the segment id (as a string).
diff --git a/doc/python/cable_cell.rst b/doc/python/cable_cell.rst
index abb51d6acef0f4705f90caf8e7bf7877f84dcbcb..b78a753ebd1a2fd431edf447345c1d6e8106424b 100644
--- a/doc/python/cable_cell.rst
+++ b/doc/python/cable_cell.rst
@@ -37,13 +37,7 @@ Cable cells
         import arbor
 
         # Construct the morphology from an SWC file.
-        tree = arbor.load_swc_arbor('granule.swc')
-        morph = arbor.morphology(tree)
-
-        # Define regions using standard SWC tags
-        labels = arbor.label_dict({'soma': '(tag 1)',
-                                   'axon': '(tag 2)',
-                                   'dend': '(join (tag 3) (tag 4))'})
+        lmrf = arbor.load_swc_arbor('granule.swc')
 
         # Define decorations
         decor = arbor.decor()
@@ -52,7 +46,7 @@ Cable cells
         decor.paint('"soma"', arbor.density('hh'))
 
         # Construct a cable cell.
-        cell = arbor.cable_cell(morph, decor, labels)
+        cell = arbor.cable_cell(lmrf.morphology, decor, lmrf.labels)
 
     .. method:: __init__(morphology, decorations, labels)
 
diff --git a/doc/python/morphology.rst b/doc/python/morphology.rst
index 77cca3c940c43b793d8f8346acbc540ffc5c24fb..6d1bfd9440d9707fa9953cdd42fb3fa52883b0b5 100644
--- a/doc/python/morphology.rst
+++ b/doc/python/morphology.rst
@@ -641,10 +641,9 @@ SWC
 NeuroML
 -------
 
-.. py:class:: neuroml_morph_data
+.. py:class:: nml_metadata
 
-    A :class:`neuroml_morphology_data` object contains a representation of a morphology defined in
-    NeuroML.
+    A :class:`nml_metadata` object contains extra information specific to NeuroML.
 
     .. py:attribute:: cell_id
        :type: optional<str>
@@ -661,11 +660,6 @@ NeuroML
 
        A map from each segment group id to its corresponding collection of segments.
 
-    .. py:attribute:: morphology
-       :type: morphology
-
-       The morphology associated with the :class:`neuroml_morph_data` object.
-
     .. py:method:: segments
 
        Returns a label dictionary with a region entry for each segment, keyed by the segment id (as a string).
@@ -722,7 +716,7 @@ NeuroML
 
       :param str morph_id: ID of the top-level morphology.
       :param bool allow_spherical_root: Treat zero length root segments especially.
-      :rtype: optional(neuroml_morph_data)
+      :rtype: optional(loaded_morphology)
 
    .. py:method:: cell_morphology(cell_id, allow_spherical_root=false)
 
@@ -731,29 +725,65 @@ NeuroML
 
       :param str morph_id: ID of the cell.
       :param bool allow_spherical_root: Treat zero length root segments especially.
-      :rtype: optional(neuroml_morph_data)
+      :rtype: optional(loaded_morphology)
 
 .. _pyasc:
 
 Neurolucida
 -----------
 
-.. py:class:: asc_morphology
+.. py:enum:: asc_marker
 
-   The morphology and label dictionary meta-data loaded from a Neurolucida ASCII ``.asc`` file.
+   One of dot, circle, cross, or none.
+
+.. py:class:: asc_color
+
+   RGB triple.
+
+.. py:class:: asc_spine
+
+   Marked spine, comprising:
+
+   .. py:attribute:: location
+
+       ``mpoint`` of the spine.
+
+   .. py:attribute:: name
+
+       Associated name.
+
+.. py:class:: asc_marker_set
 
-   .. py:attribute:: morphology
+   Locations of interest, given as
+
+   .. py:attribute:: locations
+
+       List of ``mpoint``.
+
+   .. py:attribute:: marker
+
+       Associated ``asc_marker``.
+
+   .. py:attribute:: color
+
+       Associated ``asc_color``.
+
+   .. py:attribute:: name
+
+       Associated name.
+
+.. py:class:: asc_metadata
+
+   The morphology and label dictionary meta-data loaded from a Neurolucida ASCII ``.asc`` file.
 
-       The cable cell morphology.
+   .. py:attribute:: markers
 
-   .. py:attribute:: segment_tree
+       List of marker sets in the input.
 
-       The raw segment tree.
+   .. py:attribute:: spines
 
-   .. py:attribute:: labels
+       List of spines in the input.
 
-       The labeled regions and locations extracted from the meta data. The four canonical regions are labeled
-       ``'soma'``, ``'axon'``, ``'dend'`` and ``'apic'``.
 
 .. py:function:: load_asc(filename)
 
diff --git a/doc/scripts/gen-labels.py b/doc/scripts/gen-labels.py
index 9ccf3e032a1ca379c3a16b1a45da72d4db9aa846..fe4751e866f85d7d1b09354506316899b6477f84 100644
--- a/doc/scripts/gen-labels.py
+++ b/doc/scripts/gen-labels.py
@@ -165,7 +165,8 @@ ysoma_morph3 = arbor.morphology(tree)
 fn = os.path.realpath(
     os.path.join(os.getcwd(), os.path.dirname(__file__), "../fileformat/example.swc")
 )
-swc_morph = arbor.load_swc_arbor(fn)
+swc = arbor.load_swc_arbor(fn)
+swc_morph = swc.morphology
 
 regions = {
     "empty": "(region-nil)",
diff --git a/example/single/single.cpp b/example/single/single.cpp
index d5590a5520f1cece44a2e1da81254f6bd348a6a0..200ad24a6ab1a222973843aef0d734864b623549 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -29,11 +29,11 @@ struct options {
 };
 
 options parse_options(int argc, char** argv);
-arb::morphology default_morphology();
-arb::morphology read_swc(const std::string& path);
+arborio::loaded_morphology default_morphology();
 
 struct single_recipe: public arb::recipe {
-    explicit single_recipe(arb::morphology m, arb::cv_policy pol): morpho(std::move(m)) {
+    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol):
+        morpho(std::move(m.morphology)) {
         gprop.default_parameters = arb::neuron_parameter_defaults;
         gprop.default_parameters.discretization = pol;
     }
@@ -81,7 +81,8 @@ struct single_recipe: public arb::recipe {
 int main(int argc, char** argv) {
     try {
         options opt = parse_options(argc, argv);
-        single_recipe R(opt.swc_file.empty()? default_morphology(): read_swc(opt.swc_file), opt.policy);
+        single_recipe R(opt.swc_file.empty() ? default_morphology() : arborio::load_swc_arbor(opt.swc_file),
+                        opt.policy);
 
         arb::simulation sim(R);
 
@@ -143,18 +144,14 @@ options parse_options(int argc, char** argv) {
 // of length 200 µm and radius decreasing linearly from 0.5 µm
 // to 0.2 µm.
 
-arb::morphology default_morphology() {
+arborio::loaded_morphology default_morphology() {
     arb::segment_tree tree;
 
     tree.append(arb::mnpos, { -6.3, 0.0, 0.0, 6.3}, {  6.3, 0.0, 0.0, 6.3}, 1);
     tree.append(         0, {  6.3, 0.0, 0.0, 0.5}, {206.3, 0.0, 0.0, 0.2}, 3);
 
-    return arb::morphology(tree);
-}
-
-arb::morphology read_swc(const std::string& path) {
-    std::ifstream f(path);
-    if (!f) throw std::runtime_error("unable to open SWC file: "+path);
+    auto labels = arb::label_dict{};
+    labels.add_swc_tags();
 
-    return arborio::load_swc_arbor(arborio::parse_swc(f));
+    return {tree, {tree}, labels};
 }
diff --git a/example/v_clamp/v-clamp.cpp b/example/v_clamp/v-clamp.cpp
index dbbc15170cf37fd4154db5c698513c2882d14a13..9068065cd6111535201e88b9d1f5f3d345640b14 100644
--- a/example/v_clamp/v-clamp.cpp
+++ b/example/v_clamp/v-clamp.cpp
@@ -30,11 +30,10 @@ struct options {
 };
 
 options parse_options(int argc, char** argv);
-arb::morphology default_morphology();
-arb::morphology read_swc(const std::string& path);
+arborio::loaded_morphology default_morphology();
 
 struct single_recipe: public arb::recipe {
-    explicit single_recipe(arb::morphology m, arb::cv_policy pol): morpho(std::move(m)) {
+    explicit single_recipe(arborio::loaded_morphology m, arb::cv_policy pol): morpho(std::move(m.morphology)) {
         gprop.default_parameters = arb::neuron_parameter_defaults;
         gprop.default_parameters.discretization = pol;
     }
@@ -84,7 +83,7 @@ struct single_recipe: public arb::recipe {
 int main(int argc, char** argv) {
     try {
         options opt = parse_options(argc, argv);
-        single_recipe R(opt.swc_file.empty()? default_morphology(): read_swc(opt.swc_file), opt.policy);
+        single_recipe R(opt.swc_file.empty()? default_morphology(): arborio::load_swc_arbor(opt.swc_file), opt.policy);
         R.voltage_clamp = opt.voltage;
         arb::simulation sim(R);
 
@@ -149,18 +148,13 @@ options parse_options(int argc, char** argv) {
 // of length 200 µm and radius decreasing linearly from 0.5 µm
 // to 0.2 µm.
 
-arb::morphology default_morphology() {
+arborio::loaded_morphology default_morphology() {
     arb::segment_tree tree;
 
     tree.append(arb::mnpos, { -6.3, 0.0, 0.0, 6.3}, {  6.3, 0.0, 0.0, 6.3}, 1);
     tree.append(         0, {  6.3, 0.0, 0.0, 0.5}, {206.3, 0.0, 0.0, 0.2}, 3);
 
-    return arb::morphology(tree);
-}
-
-arb::morphology read_swc(const std::string& path) {
-    std::ifstream f(path);
-    if (!f) throw std::runtime_error("unable to open SWC file: "+path);
-
-    return arborio::load_swc_arbor(arborio::parse_swc(f));
+    auto labels = arb::label_dict{};
+    labels.add_swc_tags();
+    return {tree, {tree}, labels};
 }
diff --git a/python/cells.cpp b/python/cells.cpp
index 93415fb6a8ebf1863458ccb0bc55c91426afe3e0..a28eb02a2891e9376bde63c914a1ce7cbd646166 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -305,6 +305,7 @@ void register_cells(py::module& m) {
         .def("__repr__", &lif_str)
         .def("__str__",  &lif_str);
 
+    // arb::cv_policy wrappers
     cv_policy
         .def(py::init([](const std::string& expression) { return arborio::parse_cv_policy_expression(expression).unwrap(); }),
             "expression"_a, "A valid CV policy expression")
diff --git a/python/example/gap_junctions.py b/python/example/gap_junctions.py
index 67c028a661e8a3af3cf8cbc473463276eea93423..5c7af9b6acc19adff388054698f6a80a16f2e259 100644
--- a/python/example/gap_junctions.py
+++ b/python/example/gap_junctions.py
@@ -4,7 +4,6 @@ import arbor as A
 from arbor import units as U
 import pandas as pd
 import seaborn as sns
-import matplotlib.pyplot as plt
 
 # Construct chains of cells linked with gap junctions,
 # Chains are connected by synapses.
@@ -158,5 +157,6 @@ for gid in range(ncells):
     )
 
 df = pd.concat(df_list, ignore_index=True)
-sns.relplot(data=df, kind="line", x="t/ms", y="U/mV", hue="Cell", errorbar=None)
-plt.show()
+sns.relplot(
+    data=df, kind="line", x="t/ms", y="U/mV", hue="Cell", errorbar=None
+).savefig("gap_junctions.svg")
diff --git a/python/example/probe_lfpykit.py b/python/example/probe_lfpykit.py
index d83b2732d2e2d48161ef7c6bf8e664328ea43f68..441153ed69a5f8ade5457986822e186ffb857bc0 100644
--- a/python/example/probe_lfpykit.py
+++ b/python/example/probe_lfpykit.py
@@ -61,8 +61,7 @@ else:
     print("Usage: single_cell_detailed.py [SWC file name]")
     sys.exit(1)
 
-# define morphology (needed for ``A.place_pwlin`` and ``A.cable_cell`` below)
-morphology = A.load_swc_arbor(filename)
+morphology = A.load_swc_arbor(filename).morphology
 
 # define a location on morphology for current clamp
 clamp_location = A.location(4, 1 / 6)
diff --git a/python/example/single_cell_allen.py b/python/example/single_cell_allen.py
index 6daaf09f9131a20df9d098431b72914172fabd04..0b43daf8dd87b8b20a212dce127420e6532ce187 100644
--- a/python/example/single_cell_allen.py
+++ b/python/example/single_cell_allen.py
@@ -23,10 +23,10 @@ def load_allen_fit(fit):
     # cable parameters convenience class
     @dataclass
     class parameters:
-        cm: Optional[float] = None
-        tempK: Optional[float] = None
-        Vm: Optional[float] = None
-        rL: Optional[float] = None
+        cm: Optional[U.quantity] = None
+        temp: Optional[U.quantity] = None
+        Vm: Optional[U.quantity] = None
+        rL: Optional[U.quantity] = None
 
     param = defaultdict(parameters)
     mechs = defaultdict(dict)
@@ -40,14 +40,13 @@ def load_allen_fit(fit):
         elif mech == "pas":
             # transform names and values
             if name == "cm":
-                # scaling factor NEURON -> Arbor
-                param[region].cm = value / 100.0
+                param[region].cm = value * U.uF / U.cm2
             elif name == "Ra":
-                param[region].rL = value
+                param[region].rL = value * U.Ohm * U.cm
             elif name == "Vm":
-                param[region].Vm = value
+                param[region].Vm = value * U.mV
             elif name == "celsius":
-                param[region].tempK = value + 273.15
+                param[region].temp = value * U.Celsius
             else:
                 raise Exception(f"Unknown key: {name}")
             continue
@@ -59,9 +58,9 @@ def load_allen_fit(fit):
     mechs = [(r, m, vs) for (r, m), vs in mechs.items()]
 
     default = parameters(
-        tempK=float(fit["conditions"][0]["celsius"]) + 273.15,
-        Vm=float(fit["conditions"][0]["v_init"]),
-        rL=float(fit["passive"][0]["ra"]),
+        temp=float(fit["conditions"][0]["celsius"]) * U.Celsius,
+        Vm=float(fit["conditions"][0]["v_init"]) * U.mV,
+        rL=float(fit["passive"][0]["ra"]) * U.Ohm * U.cm,
     )
 
     ions = []
@@ -71,14 +70,14 @@ def load_allen_fit(fit):
             if k == "section":
                 continue
             ion = k[1:]
-            ions.append((region, ion, float(v)))
+            ions.append((region, ion, float(v) * U.mV))
 
     return default, regs, ions, mechs, fit["fitting"][0]["junction_potential"]
 
 
 def make_cell(base, swc, fit):
     # (1) Load the swc file passed into this function
-    morphology = A.load_swc_neuron(base / swc)
+    morphology = A.load_swc_neuron(base / swc).morphology
 
     # (2) Label the region tags found in the swc with the names used in the parameter fit file.
     # In addition, label the midpoint of the soma.
@@ -93,20 +92,20 @@ def make_cell(base, swc, fit):
 
     # (5) assign global electro-physiology parameters
     decor.set_property(
-        tempK=dflt.tempK * U.Kelvin,
-        Vm=dflt.Vm * U.mV,
-        cm=dflt.cm * U.F / U.m2,
-        rL=dflt.rL * U.Ohm * U.cm,
+        tempK=dflt.temp,
+        Vm=dflt.Vm,
+        cm=dflt.cm,
+        rL=dflt.rL,
     )
 
     # (6) override regional electro-physiology parameters
     for region, vs in regions:
         decor.paint(
             f'"{region}"',
-            tempK=vs.tempK * U.Kelvin,
-            Vm=vs.Vm * U.Vm,
-            cm=vs.cm * U.F / U.m2,
-            rL=vs.rL * U.Ohm * U.cm,
+            tempK=vs.temp,
+            Vm=vs.Vm,
+            cm=vs.cm,
+            rL=vs.rL,
         )
 
     # (7) set reversal potentials
@@ -153,7 +152,7 @@ model.run(tfinal=1.4 * U.s, dt=5 * U.us)
 
 # (16) Load and scale reference
 reference = (
-    1e3 * pd.read_csv(here / "single_cell_allen_neuron_ref.csv")["U/mV"] + offset
+    1e3 * pd.read_csv(here / "single_cell_allen_neuron_ref.csv")["U/mV"][:-1] + offset
 )
 
 # (17) Plot
diff --git a/python/example/single_cell_detailed.py b/python/example/single_cell_detailed.py
index b58e2f924ac8e2f7fda811af511267e8b4fd3e6f..6b8155109f850e8afff9a3e45aaff55b3a330705 100755
--- a/python/example/single_cell_detailed.py
+++ b/python/example/single_cell_detailed.py
@@ -18,8 +18,7 @@ else:
     print("Usage: single_cell_detailed.py [SWC file name]")
     sys.exit(1)
 
-
-morph = A.load_swc_arbor(filename)
+morph = A.load_swc_arbor(filename).morphology
 
 # (2) Create and populate the label dictionary.
 labels = A.label_dict(
diff --git a/python/example/single_cell_detailed_recipe.py b/python/example/single_cell_detailed_recipe.py
index 5d28457e98283c5530521e6a634662a29ac0aee3..e34c740f13e3fc6eaf6f421b12487d88f51a9ddc 100644
--- a/python/example/single_cell_detailed_recipe.py
+++ b/python/example/single_cell_detailed_recipe.py
@@ -15,10 +15,10 @@ if len(sys.argv) == 1:
 elif len(sys.argv) == 2:
     filename = Path(sys.argv[1])
 else:
-    print("Usage: single_cell_detailed.py [SWC file name]")
+    print("Usage: single_cell_detailed_recipe.py [SWC file name]")
     sys.exit(1)
 
-morph = A.load_swc_arbor(filename)
+lmrf = A.load_swc_arbor(filename)
 
 # (2) Create and populate the label dictionary.
 labels = A.label_dict(
@@ -39,7 +39,8 @@ labels = A.label_dict(
         # Add a label for the terminal locations in the "axon" region:
         "axon_terminal": '(restrict-to (locset "terminal") (region "axon"))',
     }
-).add_swc_tags()  # Add SWC pre-defined regions
+)
+labels.append(lmrf.labels)
 
 # (3) Create and populate the decor.
 decor = (
@@ -72,7 +73,7 @@ decor = (
 
 
 # (4) Create the cell.
-cell = A.cable_cell(morph, decor, labels)
+cell = A.cable_cell(lmrf.morphology, decor, labels)
 
 
 # (5) Create a class that inherits from A.recipe
diff --git a/python/example/single_cell_nml.py b/python/example/single_cell_nml.py
index c6ea8169390b54ef6e61b1d495bc50cbd8ce7ce1..99f74e666aa68177f78244ec7543034c12b90605 100755
--- a/python/example/single_cell_nml.py
+++ b/python/example/single_cell_nml.py
@@ -4,14 +4,18 @@ from arbor import units as U
 import pandas as pd
 import seaborn as sns
 import sys
+from pathlib import Path
 
 # Load a cell morphology from an nml file.
 # Example present here: morph.nml
-if len(sys.argv) < 2:
-    print("No NeuroML file passed to the program")
-    sys.exit(0)
-
-filename = sys.argv[1]
+if len(sys.argv) == 1:
+    print("No NML file passed to the program, using default.")
+    filename = Path(__file__).parent / "morph.nml"
+elif len(sys.argv) == 2:
+    filename = Path(sys.argv[1])
+else:
+    print("Usage: single_cell_nml.py [NML file name]")
+    sys.exit(1)
 
 # Read the NeuroML morphology from the file.
 morpho_nml = A.neuroml(filename)
@@ -22,12 +26,7 @@ morpho_data = morpho_nml.morphology("m1")
 # Get the morphology.
 morpho = morpho_data.morphology
 
-# Get the region label dictionaries associated with the morphology.
-morpho_segments = morpho_data.segments()
-morpho_named = morpho_data.named_segments()
-morpho_groups = morpho_data.groups()
-
-# Create new label dict with some locsets.
+# Create new label dict with some locsets and add to it all the NeuroML dictionaries.
 labels = A.label_dict(
     {
         "stim_site": "(location 1 0.5)",  # site for the stimulus, in the middle of branch 1.
@@ -35,10 +34,7 @@ labels = A.label_dict(
         "root": "(root)",  # the start of the soma in this morphology is at the root of the cell.
     }
 )
-# Add to it all the NeuroML dictionaries.
-labels.append(morpho_segments)
-labels.append(morpho_named)
-labels.append(morpho_groups)
+labels.append(morpho_data.labels)
 
 # Optional: print out the regions and locsets available in the label dictionary.
 print("Label dictionary regions: ", labels.regions, "\n")
diff --git a/python/example/single_cell_swc.py b/python/example/single_cell_swc.py
index 097c11b60be5f61fc95913571c0f013e4e5c2f66..6455850aed6f3dcb24f854025235aa292dcce13e 100755
--- a/python/example/single_cell_swc.py
+++ b/python/example/single_cell_swc.py
@@ -15,15 +15,21 @@ from arbor import units as U
 import pandas as pd
 import seaborn as sns
 import sys
+from pathlib import Path
 
 # Load a cell morphology from an swc file.
 # Example present here: single_cell_detailed.swc
-if len(sys.argv) < 2:
-    print("No SWC file passed to the program")
-    sys.exit(0)
+# (1) Read the morphology from an SWC file.
+if len(sys.argv) == 1:
+    print("No SWC file passed to the program, using default.")
+    filename = Path(__file__).parent / "single_cell_detailed.swc"
+elif len(sys.argv) == 2:
+    filename = Path(sys.argv[1])
+else:
+    print("Usage: single_cell_swc.py [SWC file name]")
+    sys.exit(1)
 
-filename = sys.argv[1]
-morpho = A.load_swc_arbor(filename)
+morpho = A.load_swc_arbor(filename).morphology
 
 # Define the regions and locsets in the model.
 labels = A.label_dict(
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index e58a8a035de0ce86c80c12eb34431326d83cc787..be12c7d345d952cf8eb23200630ea1c8f16be7fc 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -50,9 +50,14 @@ void register_identifiers(py::module& m) {
              "Construct a cell_local_label identifier with arguments:\n"
              "  label:  The identifier of a group of one or more items on a cell.\n"
              "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
-        .def(py::init([](py::tuple t) {
-               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
-               return arb::cell_local_label_type{t[0].cast<arb::cell_tag_type>(), t[1].cast<arb::lid_selection_policy>()};
+        .def(py::init([](const std::tuple<arb::cell_tag_type, arb::lid_selection_policy>& t) {
+               return arb::cell_local_label_type{std::get<arb::cell_tag_type>(t), std::get<arb::lid_selection_policy>(t)};
+             }),
+             "Construct a cell_local_label identifier with tuple argument (label, policy):\n"
+             "  label:  The identifier of a group of one or more items on a cell.\n"
+             "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
+        .def(py::init([](const std::pair<arb::cell_tag_type, arb::lid_selection_policy>& t) {
+               return arb::cell_local_label_type{std::get<arb::cell_tag_type>(t), std::get<arb::lid_selection_policy>(t)};
              }),
              "Construct a cell_local_label identifier with tuple argument (label, policy):\n"
              "  label:  The identifier of a group of one or more items on a cell.\n"
@@ -64,8 +69,10 @@ void register_identifiers(py::module& m) {
         .def("__str__", [](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);})
         .def("__repr__",[](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);});
 
+    py::implicitly_convertible<std::pair<arb::cell_tag_type, arb::lid_selection_policy>, arb::cell_local_label_type>();
+    py::implicitly_convertible<std::tuple<arb::cell_tag_type, arb::lid_selection_policy>, arb::cell_local_label_type>();
     py::implicitly_convertible<py::tuple, arb::cell_local_label_type>();
-    py::implicitly_convertible<py::str, arb::cell_local_label_type>();
+    py::implicitly_convertible<arb::cell_tag_type, arb::cell_local_label_type>();
 
     py::class_<arb::cell_global_label_type> cell_global_label_type(m, "cell_global_label",
         "For global identification of an item.\n\n"
@@ -89,13 +96,18 @@ void register_identifiers(py::module& m) {
              "Construct a cell_global_label identifier with arguments:\n"
              "  gid:   The global identifier of the cell.\n"
              "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
-        .def(py::init([](py::tuple t) {
-               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
-               return arb::cell_global_label_type{t[0].cast<arb::cell_gid_type>(), t[1].cast<arb::cell_local_label_type>()};
+        .def(py::init([](const std::tuple<arb::cell_gid_type, arb::cell_local_label_type>& t) {
+               return arb::cell_global_label_type{std::get<arb::cell_gid_type>(t), std::get<arb::cell_local_label_type>(t)};
              }),
              "Construct a cell_global_label identifier with tuple argument (gid, label):\n"
              "  gid:   The global identifier of the cell.\n"
              "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
+        .def(py::init([](const std::tuple<arb::cell_gid_type, arb::cell_tag_type>& t) {
+               return arb::cell_global_label_type{std::get<arb::cell_gid_type>(t), std::get<arb::cell_tag_type>(t)};
+             }),
+             "Construct a cell_global_label identifier with tuple argument (gid, label):\n"
+             "  gid:   The global identifier of the cell.\n"
+             "  label: The tag of an item on the cell.\n")
         .def_readwrite("gid",  &arb::cell_global_label_type::gid,
              "The global identifier of the cell.")
         .def_readwrite("label", &arb::cell_global_label_type::label,
@@ -103,6 +115,8 @@ void register_identifiers(py::module& m) {
         .def("__str__", [](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);})
         .def("__repr__",[](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);});
 
+    py::implicitly_convertible<std::tuple<arb::cell_gid_type, arb::cell_local_label_type>, arb::cell_global_label_type>();
+    py::implicitly_convertible<std::tuple<arb::cell_gid_type, arb::cell_tag_type>, arb::cell_global_label_type>();
     py::implicitly_convertible<py::tuple, arb::cell_global_label_type>();
 
     py::class_<arb::cell_member_type> cell_member(m, "cell_member",
diff --git a/python/label_dict.cpp b/python/label_dict.cpp
index ff71d1481d84382750a00c64ac65b6ae4b75b731..3ec8713caadbf2cc97088003b1e71dbfc7ad247a 100644
--- a/python/label_dict.cpp
+++ b/python/label_dict.cpp
@@ -68,13 +68,13 @@ void register_label_dict(py::module& m) {
              },
              py::keep_alive<0, 1>())
         .def("append", [](label_dict_proxy& l, const label_dict_proxy& other, const char* prefix) {
-                l.import(other, prefix);
+                return l.extend(other, prefix);
             },
             "other"_a, "The label_dict to be imported"
             "prefix"_a="", "optional prefix appended to the region and locset labels",
             "Import the entries of a another label dictionary with an optional prefix.")
         .def("update", [](label_dict_proxy& l, const label_dict_proxy& other) {
-                l.import(other);
+                return l.extend(other);
             },
             "other"_a, "The label_dict to be imported"
             "Import the entries of a another label dictionary.")
diff --git a/python/label_dict.hpp b/python/label_dict.hpp
index 9d62fd1455bee646b3d0dc75327ba56c23cbac26..1e604512349f56e97327a02b0b0f72456827e2ba 100644
--- a/python/label_dict.hpp
+++ b/python/label_dict.hpp
@@ -48,11 +48,11 @@ struct label_dict_proxy {
         return locsets.size() + regions.size() + iexpressions.size();
     }
 
-    void import(const label_dict_proxy& other, std::string prefix = "") {
-        dict.import(other.dict, prefix);
-
+    auto& extend(const label_dict_proxy& other, std::string prefix = "") {
+        dict.extend(other.dict, prefix);
         clear_cache();
         update_cache();
+        return *this;
     }
 
     void set(const std::string& name, const std::string& desc) {
diff --git a/python/morphology.cpp b/python/morphology.cpp
index dacf19ede0fe8f121d404c3c5fc342825e0eb6d1..1a3c305e488ed4e213d79a592cdff3bdd502b00e 100644
--- a/python/morphology.cpp
+++ b/python/morphology.cpp
@@ -51,12 +51,23 @@ void register_morphology(py::module& m) {
     py::class_<arb::isometry> isometry(m, "isometry");
     py::class_<arb::place_pwlin> place(m, "place_pwlin");
     py::class_<arb::segment_tree> segment_tree(m, "segment_tree");
-    py::class_<arborio::asc_morphology> asc_morphology(m, "asc_morphology", "The morphology and label dictionary meta-data loaded from a Neurolucida ASCII (.asc) file.");
-    py::class_<arborio::nml_morphology_data> nml_morph_data(m, "neuroml_morph_data");
     py::class_<arborio::neuroml> neuroml(m, "neuroml");
     py::class_<arb::mprovider> prov(m, "morphology_provider");
     py::class_<arb::msegment> msegment(m, "msegment");
 
+    py::class_<arborio::nml_metadata> nml_meta(m, "nml_metadata");
+    py::class_<arborio::asc_metadata> asc_meta(m,
+                                               "asc_metadata",
+                                               "Neurolucida metadata type: Spines and marker sets.");
+    py::class_<arborio::loaded_morphology> loaded_morphology(m,
+                                                             "loaded_morphology",
+                                                             "The morphology and label dictionary meta-data loaded from file.");
+
+    py::class_<arborio::swc_metadata> swc_meta(m,
+                                               "swc_metadata",
+                                               "SWC metadata type: empty.");
+
+
     // arb::mlocation
     location
         .def(py::init(
@@ -84,9 +95,8 @@ void register_morphology(py::module& 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>()}; }),
+        .def(py::init([](const std::tuple<double, double, double, double>& t) {
+                return arb::mpoint{std::get<0>(t), std::get<1>(t), std::get<2>(t), std::get<3>(t)}; }),
              "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].")
@@ -101,6 +111,7 @@ void register_morphology(py::module& m) {
         .def("__repr__",
             [](const arb::mpoint& p) {return util::pprintf("{}", p);});
 
+    py::implicitly_convertible<const std::tuple<double, double, double, double>&, arb::mpoint>();
     py::implicitly_convertible<py::tuple, arb::mpoint>();
 
     // arb::msegment
@@ -315,18 +326,15 @@ void register_morphology(py::module& m) {
                     return util::pprintf("<arbor.morphology:\n{}>", m);
                 });
 
-    using morph_or_tree = std::variant<arb::segment_tree, arb::morphology>;
+    py::implicitly_convertible<arb::segment_tree, arb::morphology>();
 
     // Function that creates a morphology/segment_tree from an swc file.
     // Wraps calls to C++ functions arborio::parse_swc() and arborio::load_swc_arbor().
     m.def("load_swc_arbor",
-        [](py::object fn, bool raw) -> morph_or_tree {
+        [](py::object fn) -> arborio::loaded_morphology {
             try {
                 auto contents = util::read_file_or_buffer(fn);
                 auto data = arborio::parse_swc(contents);
-                if (raw) {
-                    return arborio::load_swc_arbor_raw(data);
-                }
                 return arborio::load_swc_arbor(data);
             }
             catch (arborio::swc_error& e) {
@@ -334,8 +342,8 @@ void register_morphology(py::module& m) {
                 throw pyarb_error(util::pprintf("Arbor SWC: parse error: {}", e.what()));
             }
         },
-        "filename_or_stream"_a, "raw"_a=false,
-        "Generate a morphology/segment_tree (raw=False/True) from an SWC file following the rules prescribed by Arbor.\n"
+        "filename_or_stream"_a,
+        "Generate a morphology/segment_tree from an SWC file following the rules prescribed by Arbor.\n"
         "Specifically:\n"
         " * Single-segment somas are disallowed.\n"
         " * There are no special rules related to somata. They can be one or multiple branches\n"
@@ -343,13 +351,10 @@ void register_morphology(py::module& m) {
         " * A segment is always created between a sample and its parent, meaning there\n"
         "   are no gaps in the resulting morphology.");
     m.def("load_swc_neuron",
-        [](py::object fn, bool raw) -> morph_or_tree {
+        [](py::object fn) -> arborio::loaded_morphology {
             try {
                 auto contents = util::read_file_or_buffer(fn);
                 auto data = arborio::parse_swc(contents);
-                if (raw) {
-                    return arborio::load_swc_neuron_raw(data);
-                }
                 return arborio::load_swc_neuron(data);
             }
             catch (arborio::swc_error& e) {
@@ -357,33 +362,70 @@ void register_morphology(py::module& m) {
                 throw pyarb_error(util::pprintf("NEURON SWC: parse error: {}", e.what()));
             }
         },
-        "filename_or_stream"_a, "raw"_a=false,
-        "Generate a morphology/segment_tree (raw=False/True) from an SWC file following the rules prescribed by NEURON.\n"
+        "filename_or_stream"_a,
+        "Generate a morphology from an SWC file following the rules prescribed by NEURON.\n"
         "See the documentation https://docs.arbor-sim.org/en/latest/fileformat/swc.html\n"
         "for a detailed description of the interpretation.");
 
     // Neurolucida ASCII, or .asc, file format support.
-
-    asc_morphology
+    py::class_<arborio::asc_color> color(m,
+                                         "asc_color",
+                                         "Neurolucida color tag.");
+    color
+        .def_readonly("red", &arborio::asc_color::r)
+        .def_readonly("blue", &arborio::asc_color::b)
+        .def_readonly("green", &arborio::asc_color::g);
+
+
+    py::class_<arborio::asc_spine> spine(m,
+                                         "asc_spine",
+                                         "Neurolucida spine marker.");
+    spine
+        .def_readonly("name", &arborio::asc_spine::name)
+        .def_readonly("location", &arborio::asc_spine::location);
+
+
+    py::enum_<arborio::asc_marker> marker(m,
+                                           "asc_marker",
+                                           "Neurolucida marker type.");
+    marker
+        .value("dot", arborio::asc_marker::dot)
+        .value("cross", arborio::asc_marker::cross)
+        .value("circle", arborio::asc_marker::circle)
+        .value("none", arborio::asc_marker::none);
+
+    py::class_<arborio::asc_marker_set> marker_set(m,
+                                                  "asc_marker_set",
+                                                  "Neurolucida marker set type.");
+
+    marker_set
+        .def_readonly("name", &arborio::asc_marker_set::name)
+        .def_readonly("marker", &arborio::asc_marker_set::marker)
+        .def_readonly("color", &arborio::asc_marker_set::color)
+        .def_readonly("locations", &arborio::asc_marker_set::locations);
+
+    asc_meta
+        .def_readonly("markers", &arborio::asc_metadata::markers)
+        .def_readonly("spines", &arborio::asc_metadata::spines);
+
+    loaded_morphology
         .def_readonly("morphology",
-                &arborio::asc_morphology::morphology,
+                &arborio::loaded_morphology::morphology,
                 "The cable cell morphology.")
         .def_readonly("segment_tree",
-                &arborio::asc_morphology::segment_tree,
+                &arborio::loaded_morphology::segment_tree,
                 "The raw segment tree.")
+        .def_readonly("metadata",
+                &arborio::loaded_morphology::metadata,
+                "File type specific metadata.")
         .def_property_readonly("labels",
-            [](const arborio::asc_morphology& m) {return label_dict_proxy(m.labels);},
-            "The four canonical regions are labeled 'soma', 'axon', 'dend' and 'apic'.");
-
-    using asc_morph_or_tree = std::variant<arb::segment_tree, arborio::asc_morphology>;
+            [](const arborio::loaded_morphology& m) {return label_dict_proxy(m.labels);},
+            "Any labels defined by the loaded file.");
 
     m.def("load_asc",
-        [](py::object fn, bool raw) -> asc_morph_or_tree {
+        [](py::object fn) -> arborio::loaded_morphology {
             try {
                 auto contents = util::read_file_or_buffer(fn);
-                if (raw) {
-                    return arborio::parse_asc_string_raw(contents.c_str());
-                }
                 return arborio::parse_asc_string(contents.c_str());
             }
             catch (std::exception& e) {
@@ -391,31 +433,28 @@ void register_morphology(py::module& m) {
                 throw pyarb_error(util::pprintf("error loading neurolucida asc file: {}", e.what()));
             }
         },
-        "filename_or_stream"_a, "raw"_a=false,
-        "Load a morphology or segment_tree (raw=True) and meta data from a Neurolucida ASCII .asc file.");
+        "filename_or_stream"_a,
+        "Load a morphology or segment_tree and meta data from a Neurolucida ASCII .asc file.");
 
     // arborio::morphology_data
-    nml_morph_data
+    nml_meta
         .def_readonly("cell_id",
-            &arborio::nml_morphology_data::cell_id,
+            &arborio::nml_metadata::cell_id,
             "Cell id, or empty if morphology was taken from a top-level <morphology> element.")
         .def_readonly("id",
-            &arborio::nml_morphology_data::id,
+            &arborio::nml_metadata::id,
             "Morphology id.")
-        .def_readonly("morphology",
-            &arborio::nml_morphology_data::morphology,
-            "Morphology constructed from a signle NeuroML <morphology> element.")
         .def("segments",
-            [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.segments);},
+            [](const arborio::nml_metadata& md) {return label_dict_proxy(md.segments);},
             "Label dictionary containing one region expression for each segment id.")
         .def("named_segments",
-            [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.named_segments);},
+            [](const arborio::nml_metadata& md) {return label_dict_proxy(md.named_segments);},
             "Label dictionary containing one region expression for each name applied to one or more segments.")
         .def("groups",
-            [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.groups);},
+            [](const arborio::nml_metadata& md) {return label_dict_proxy(md.groups);},
             "Label dictionary containing one region expression for each segmentGroup id.")
         .def_readonly("group_segments",
-            &arborio::nml_morphology_data::group_segments,
+            &arborio::nml_metadata::group_segments,
             "Map from segmentGroup ids to their corresponding segment ids.");
 
     // arborio::neuroml
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index a835da796369123fc1cac842a5c0dbd36c5c5497..7f77d673a205e0482f86ac52e975d40b43b6435c 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -9,6 +9,7 @@
 #include <arbor/version.hpp>
 
 #include "pyarb.hpp"
+#include "arbor/morph/primitives.hpp"
 
 // Forward declarations of functions used to register API
 // types and functions to be exposed to Python.
@@ -96,6 +97,7 @@ PYBIND11_MODULE(_arbor, m) {
     pybind11::register_exception<arb::file_not_found_error>(m, "ArbFileNotFoundError", PyExc_FileNotFoundError);
     pybind11::register_exception<arb::zero_thread_requested_error>(m, "ArbValueError", PyExc_ValueError);
 
+    pybind11::implicitly_convertible<const std::tuple<double, double, double, double>&, arb::mpoint>();
 
     #ifdef ARB_MPI_ENABLED
     pyarb::register_mpi(m);
diff --git a/python/stubs/arbor/__init__.pyi b/python/stubs/arbor/__init__.pyi
index 12a28c97542f832931e69a9e5ca229d25441ef21..83f19fd01b9664d59e9add1ba97d832d4ea0bbc2 100644
--- a/python/stubs/arbor/__init__.pyi
+++ b/python/stubs/arbor/__init__.pyi
@@ -5,7 +5,11 @@ from arbor._arbor import MechCatItemIterator
 from arbor._arbor import MechCatKeyIterator
 from arbor._arbor import MechCatValueIterator
 from arbor._arbor import allen_catalogue
-from arbor._arbor import asc_morphology
+from arbor._arbor import asc_color
+from arbor._arbor import asc_marker
+from arbor._arbor import asc_marker_set
+from arbor._arbor import asc_metadata
+from arbor._arbor import asc_spine
 from arbor._arbor import axial_resistivity
 from arbor._arbor import backend
 from arbor._arbor import bbp_catalogue
@@ -81,6 +85,7 @@ from arbor._arbor import load_catalogue
 from arbor._arbor import load_component
 from arbor._arbor import load_swc_arbor
 from arbor._arbor import load_swc_neuron
+from arbor._arbor import loaded_morphology
 from arbor._arbor import location
 from arbor._arbor import mechanism
 from arbor._arbor import mechanism_field
@@ -94,8 +99,8 @@ from arbor._arbor import morphology_provider
 from arbor._arbor import mpoint
 from arbor._arbor import msegment
 from arbor._arbor import neuroml
-from arbor._arbor import neuroml_morph_data
 from arbor._arbor import neuron_cable_properties
+from arbor._arbor import nml_metadata
 from arbor._arbor import partition_by_group
 from arbor._arbor import partition_hint
 from arbor._arbor import partition_load_balance
@@ -118,6 +123,7 @@ from arbor._arbor import spike
 from arbor._arbor import spike_recording
 from arbor._arbor import spike_source_cell
 from arbor._arbor import stochastic_catalogue
+from arbor._arbor import swc_metadata
 from arbor._arbor import synapse
 from arbor._arbor import temperature
 from arbor._arbor import threshold_detector
@@ -134,7 +140,11 @@ __all__ = [
     "MechCatKeyIterator",
     "MechCatValueIterator",
     "allen_catalogue",
-    "asc_morphology",
+    "asc_color",
+    "asc_marker",
+    "asc_marker_set",
+    "asc_metadata",
+    "asc_spine",
     "axial_resistivity",
     "backend",
     "bbp_catalogue",
@@ -211,6 +221,7 @@ __all__ = [
     "load_component",
     "load_swc_arbor",
     "load_swc_neuron",
+    "loaded_morphology",
     "location",
     "mechanism",
     "mechanism_field",
@@ -226,8 +237,8 @@ __all__ = [
     "mpoint",
     "msegment",
     "neuroml",
-    "neuroml_morph_data",
     "neuron_cable_properties",
+    "nml_metadata",
     "partition_by_group",
     "partition_hint",
     "partition_load_balance",
@@ -250,6 +261,7 @@ __all__ = [
     "spike_recording",
     "spike_source_cell",
     "stochastic_catalogue",
+    "swc_metadata",
     "synapse",
     "temperature",
     "threshold_detector",
@@ -271,17 +283,17 @@ __config__: dict = {
     "neuroml": True,
     "bundled": True,
     "version": "0.9.1-dev",
-    "source": "2023-12-08T14:40:50+01:00 327c56d229571dac097e7400a9b5e04fc8d7a514 modified",
+    "source": "2024-03-01T14:59:23+01:00 dcdfe101f389cb4854ac3d0a067feeb280600c88 modified",
     "build_config": "DEBUG",
     "arch": "native",
     "prefix": "/usr/local",
-    "python_lib_path": "/opt/homebrew/lib/python3.11/site-packages",
+    "python_lib_path": "/usr/local/lib/python3.12/site-packages",
     "binary_path": "bin",
     "lib_path": "lib",
     "data_path": "share",
     "CXX": "/opt/homebrew/bin/clang++",
     "pybind-version": "2.11.1",
-    "timestamp": "Jan  2 2024 09:57:33",
+    "timestamp": "Mar  4 2024 20:56:20",
 }
 __version__: str = "0.9.1-dev"
 mnpos: int = 4294967295
diff --git a/python/stubs/arbor/_arbor/__init__.pyi b/python/stubs/arbor/_arbor/__init__.pyi
index a0ae1484c9289fb442e6b12abc4d53bf54aae61a..94e4178308e9d49469609239efb68182f884bc0b 100644
--- a/python/stubs/arbor/_arbor/__init__.pyi
+++ b/python/stubs/arbor/_arbor/__init__.pyi
@@ -14,7 +14,11 @@ __all__ = [
     "MechCatKeyIterator",
     "MechCatValueIterator",
     "allen_catalogue",
-    "asc_morphology",
+    "asc_color",
+    "asc_marker",
+    "asc_marker_set",
+    "asc_metadata",
+    "asc_spine",
     "axial_resistivity",
     "backend",
     "bbp_catalogue",
@@ -90,6 +94,7 @@ __all__ = [
     "load_component",
     "load_swc_arbor",
     "load_swc_neuron",
+    "loaded_morphology",
     "location",
     "mechanism",
     "mechanism_field",
@@ -104,8 +109,8 @@ __all__ = [
     "mpoint",
     "msegment",
     "neuroml",
-    "neuroml_morph_data",
     "neuron_cable_properties",
+    "nml_metadata",
     "partition_by_group",
     "partition_hint",
     "partition_load_balance",
@@ -128,6 +133,7 @@ __all__ = [
     "spike_recording",
     "spike_source_cell",
     "stochastic_catalogue",
+    "swc_metadata",
     "synapse",
     "temperature",
     "threshold_detector",
@@ -155,28 +161,88 @@ class MechCatValueIterator:
     def __iter__(self) -> MechCatValueIterator: ...
     def __next__(self) -> mechanism_info: ...
 
-class asc_morphology:
+class asc_color:
     """
-    The morphology and label dictionary meta-data loaded from a Neurolucida ASCII (.asc) file.
+    Neurolucida color tag.
     """
 
     @property
-    def labels(self) -> label_dict:
-        """
-        The four canonical regions are labeled 'soma', 'axon', 'dend' and 'apic'.
-        """
+    def blue(self) -> int: ...
+    @property
+    def green(self) -> int: ...
+    @property
+    def red(self) -> int: ...
+
+class asc_marker:
+    """
+    Neurolucida marker type.
+
+    Members:
 
+      dot
+
+      cross
+
+      circle
+
+      none
+    """
+
+    __members__: typing.ClassVar[
+        dict[str, asc_marker]
+    ]  # value = {'dot': <asc_marker.dot: 0>, 'cross': <asc_marker.cross: 2>, 'circle': <asc_marker.circle: 1>, 'none': <asc_marker.none: 3>}
+    circle: typing.ClassVar[asc_marker]  # value = <asc_marker.circle: 1>
+    cross: typing.ClassVar[asc_marker]  # value = <asc_marker.cross: 2>
+    dot: typing.ClassVar[asc_marker]  # value = <asc_marker.dot: 0>
+    none: typing.ClassVar[asc_marker]  # value = <asc_marker.none: 3>
+    def __eq__(self, other: typing.Any) -> bool: ...
+    def __getstate__(self) -> int: ...
+    def __hash__(self) -> int: ...
+    def __index__(self) -> int: ...
+    def __init__(self, value: int) -> None: ...
+    def __int__(self) -> int: ...
+    def __ne__(self, other: typing.Any) -> bool: ...
+    def __repr__(self) -> str: ...
+    def __setstate__(self, state: int) -> None: ...
+    def __str__(self) -> str: ...
     @property
-    def morphology(self) -> morphology:
-        """
-        The cable cell morphology.
-        """
+    def name(self) -> str: ...
+    @property
+    def value(self) -> int: ...
+
+class asc_marker_set:
+    """
+    Neurolucida marker set type.
+    """
 
     @property
-    def segment_tree(self) -> segment_tree:
-        """
-        The raw segment tree.
-        """
+    def color(self) -> asc_color: ...
+    @property
+    def locations(self) -> list[mpoint]: ...
+    @property
+    def marker(self) -> asc_marker: ...
+    @property
+    def name(self) -> str: ...
+
+class asc_metadata:
+    """
+    Neurolucida metadata type: Spines and marker sets.
+    """
+
+    @property
+    def markers(self) -> list[asc_marker_set]: ...
+    @property
+    def spines(self) -> list[asc_spine]: ...
+
+class asc_spine:
+    """
+    Neurolucida spine marker.
+    """
+
+    @property
+    def location(self) -> mpoint: ...
+    @property
+    def name(self) -> str: ...
 
 class axial_resistivity:
     """
@@ -199,9 +265,9 @@ class backend:
 
     __members__: typing.ClassVar[
         dict[str, backend]
-    ]  # value = {'gpu': <backend.gpu: 1>, 'multicore': <backend.multicore: 0>}
-    gpu: typing.ClassVar[backend]  # value = <backend.gpu: 1>
-    multicore: typing.ClassVar[backend]  # value = <backend.multicore: 0>
+    ]  # value = {'gpu': <backend.gpu: 0>, 'multicore': <backend.multicore: 1>}
+    gpu: typing.ClassVar[backend]  # value = <backend.gpu: 0>
+    multicore: typing.ClassVar[backend]  # value = <backend.multicore: 1>
     def __eq__(self, other: typing.Any) -> bool: ...
     def __getstate__(self) -> int: ...
     def __hash__(self) -> int: ...
@@ -413,8 +479,6 @@ class cable_global_properties:
 
     @property
     def axial_resistivity(self) -> float | None: ...
-    @axial_resistivity.setter
-    def axial_resistivity(self, arg1: float) -> None: ...
     @property
     def catalogue(self) -> catalogue:
         """
@@ -445,16 +509,10 @@ class cable_global_properties:
 
     @property
     def membrane_capacitance(self) -> float | None: ...
-    @membrane_capacitance.setter
-    def membrane_capacitance(self, arg1: float) -> None: ...
     @property
     def membrane_potential(self) -> float | None: ...
-    @membrane_potential.setter
-    def membrane_potential(self, arg1: float) -> None: ...
     @property
     def temperature(self) -> float | None: ...
-    @temperature.setter
-    def temperature(self, arg1: float) -> None: ...
 
 class cable_probe_point_info:
     """
@@ -595,13 +653,21 @@ class cell_global_label:
         """
 
     @typing.overload
-    def __init__(self, arg0: tuple) -> None:
+    def __init__(self, arg0: tuple[int, cell_local_label]) -> None:
         """
         Construct a cell_global_label identifier with tuple argument (gid, label):
           gid:   The global identifier of the cell.
           label: The cell_local_label representing the label and selection policy of an item on the cell.
         """
 
+    @typing.overload
+    def __init__(self, arg0: tuple[int, str]) -> None:
+        """
+        Construct a cell_global_label identifier with tuple argument (gid, label):
+          gid:   The global identifier of the cell.
+          label: The tag of an item on the cell.
+        """
+
     def __repr__(self) -> str: ...
     def __str__(self) -> str: ...
     @property
@@ -683,7 +749,15 @@ class cell_local_label:
         """
 
     @typing.overload
-    def __init__(self, arg0: tuple) -> None:
+    def __init__(self, arg0: tuple[str, selection_policy]) -> None:
+        """
+        Construct a cell_local_label identifier with tuple argument (label, policy):
+          label:  The identifier of a group of one or more items on a cell.
+          policy: The policy for selecting one of possibly multiple items associated with the label.
+        """
+
+    @typing.overload
+    def __init__(self, arg0: tuple[str, selection_policy]) -> None:
         """
         Construct a cell_local_label identifier with tuple argument (label, policy):
           label:  The identifier of a group of one or more items on a cell.
@@ -980,10 +1054,10 @@ class decor:
     def paint(
         self,
         region: str,
-        Vm: units.quantity | str | None = None,
-        cm: units.quantity | str | None = None,
-        rL: units.quantity | str | None = None,
-        tempK: units.quantity | str | None = None,
+        Vm: units.quantity | tuple[units.quantity, str] | None = None,
+        cm: units.quantity | tuple[units.quantity, str] | None = None,
+        rL: units.quantity | tuple[units.quantity, str] | None = None,
+        tempK: units.quantity | tuple[units.quantity, str] | None = None,
     ) -> decor:
         """
         Set cable properties on a region.
@@ -992,6 +1066,7 @@ class decor:
          * cm:    membrane capacitance [F/m²].
          * rL:    axial resistivity [Ω·cm].
          * tempK: temperature [Kelvin].
+        Each value can be given as a plain quantity or a tuple of (quantity, 'scale') where scale is an iexpr.
         """
 
     @typing.overload
@@ -1000,10 +1075,10 @@ class decor:
         region: str,
         *,
         ion: str,
-        int_con: units.quantity | None = None,
-        ext_con: units.quantity | None = None,
-        rev_pot: units.quantity | None = None,
-        diff: units.quantity | None = None,
+        int_con: units.quantity | tuple[units.quantity, str] | None = None,
+        ext_con: units.quantity | tuple[units.quantity, str] | None = None,
+        rev_pot: units.quantity | tuple[units.quantity, str] | None = None,
+        diff: units.quantity | tuple[units.quantity, str] | None = None,
     ) -> decor:
         """
         Set ion species properties conditions on a region.
@@ -1012,6 +1087,7 @@ class decor:
          * rev_pot: reversal potential [mV].
          * method:  mechanism for calculating reversal potential.
          * diff:   diffusivity [m^2/s].
+        Each value can be given as a plain quantity or a tuple of (quantity, 'scale') where scale is an iexpr.
         """
 
     def paintings(
@@ -1561,7 +1637,7 @@ class label_dict:
     """
 
     @staticmethod
-    def append(*args, **kwargs) -> None:
+    def append(*args, **kwargs) -> label_dict:
         """
         Import the entries of a another label dictionary with an optional prefix.
         """
@@ -1608,7 +1684,7 @@ class label_dict:
 
     def items(self) -> typing.Iterator: ...
     def keys(self) -> typing.Iterator: ...
-    def update(self, other: label_dict) -> None:
+    def update(self, other: label_dict) -> label_dict:
         """
         The label_dict to be importedImport the entries of a another label dictionary.
         """
@@ -1733,6 +1809,35 @@ class lif_probe_metadata:
     Probe metadata associated with a LIF cell probe.
     """
 
+class loaded_morphology:
+    """
+    The morphology and label dictionary meta-data loaded from file.
+    """
+
+    @property
+    def labels(self) -> label_dict:
+        """
+        Any labels defined by the loaded file.
+        """
+
+    @property
+    def metadata(self) -> swc_metadata | asc_metadata | nml_metadata:
+        """
+        File type specific metadata.
+        """
+
+    @property
+    def morphology(self) -> morphology:
+        """
+        The cable cell morphology.
+        """
+
+    @property
+    def segment_tree(self) -> segment_tree:
+        """
+        The raw segment tree.
+        """
+
 class location:
     """
     A location on a cable cell.
@@ -1893,7 +1998,7 @@ class membrane_potential:
     Setting the initial membrane voltage.
     """
 
-    def __init__(self, arg0: units.quantity, arg1: str | None) -> None: ...
+    def __init__(self, arg0: units.quantity) -> None: ...
     def __repr__(self) -> str: ...
 
 class meter_manager:
@@ -1941,40 +2046,6 @@ class morphology:
     A cell morphology.
     """
 
-    def __init__(self, arg0: segment_tree) -> None: ...
-    def __str__(self) -> str: ...
-    def branch_children(self, i: int) -> list[int]:
-        """
-        The child branches of branch i.
-        """
-
-    def branch_parent(self, i: int) -> int:
-        """
-        The parent branch of branch i.
-        """
-
-    def branch_segments(self, i: int) -> list[msegment]:
-        """
-        A list of the segments in branch i, ordered from proximal to distal ends of the branch.
-        """
-
-    def to_segment_tree(self) -> segment_tree:
-        """
-        Convert this morphology to a segment_tree.
-        """
-
-    @property
-    def empty(self) -> bool:
-        """
-        Whether the morphology is empty.
-        """
-
-    @property
-    def num_branches(self) -> int:
-        """
-        The number of branches in the morphology.
-        """
-
 class morphology_provider:
     def __init__(self, morphology: morphology) -> None:
         """
@@ -2001,7 +2072,7 @@ class mpoint:
         """
 
     @typing.overload
-    def __init__(self, arg0: tuple) -> None:
+    def __init__(self, arg0: tuple[float, float, float, float]) -> None:
         """
         Create an mpoint object from a tuple (x, y, z, radius), specified in µm.
         """
@@ -2064,14 +2135,14 @@ class neuroml:
 
     def cell_morphology(
         self, cell_id: str, allow_spherical_root: bool = False
-    ) -> neuroml_morph_data | None:
+    ) -> loaded_morphology | None:
         """
         Retrieve nml_morph_data associated with cell_id.
         """
 
     def morphology(
         self, morph_id: str, allow_spherical_root: bool = False
-    ) -> neuroml_morph_data | None:
+    ) -> loaded_morphology | None:
         """
         Retrieve top-level nml_morph_data associated with morph_id.
         """
@@ -2081,7 +2152,7 @@ class neuroml:
         Query top-level standalone morphologies.
         """
 
-class neuroml_morph_data:
+class nml_metadata:
     def groups(self) -> label_dict:
         """
         Label dictionary containing one region expression for each segmentGroup id.
@@ -2115,12 +2186,6 @@ class neuroml_morph_data:
         Morphology id.
         """
 
-    @property
-    def morphology(self) -> morphology:
-        """
-        Morphology constructed from a signle NeuroML <morphology> element.
-        """
-
 class partition_hint:
     """
     Provide a hint on how the cell groups should be partitioned.
@@ -2896,6 +2961,11 @@ class spike_source_cell:
     def __repr__(self) -> str: ...
     def __str__(self) -> str: ...
 
+class swc_metadata:
+    """
+    SWC metadata type: empty.
+    """
+
 class synapse:
     """
     For placing a synaptic mechanism on a locset.
@@ -3150,11 +3220,9 @@ def lif_probe_voltage(tag: str) -> probe:
     Probe specification for LIF cell membrane voltage.
     """
 
-def load_asc(
-    filename_or_stream: typing.Any, raw: bool = False
-) -> segment_tree | asc_morphology:
+def load_asc(filename_or_stream: typing.Any) -> loaded_morphology:
     """
-    Load a morphology or segment_tree (raw=True) and meta data from a Neurolucida ASCII .asc file.
+    Load a morphology or segment_tree and meta data from a Neurolucida ASCII .asc file.
     """
 
 def load_catalogue(arg0: typing.Any) -> catalogue: ...
@@ -3163,11 +3231,9 @@ def load_component(filename_or_descriptor: typing.Any) -> cable_component:
     Load arbor-component (decor, morphology, label_dict, cable_cell) from file.
     """
 
-def load_swc_arbor(
-    filename_or_stream: typing.Any, raw: bool = False
-) -> segment_tree | morphology:
+def load_swc_arbor(filename_or_stream: typing.Any) -> loaded_morphology:
     """
-    Generate a morphology/segment_tree (raw=False/True) from an SWC file following the rules prescribed by Arbor.
+    Generate a morphology/segment_tree from an SWC file following the rules prescribed by Arbor.
     Specifically:
      * Single-segment somas are disallowed.
      * There are no special rules related to somata. They can be one or multiple branches
@@ -3176,11 +3242,9 @@ def load_swc_arbor(
        are no gaps in the resulting morphology.
     """
 
-def load_swc_neuron(
-    filename_or_stream: typing.Any, raw: bool = False
-) -> segment_tree | morphology:
+def load_swc_neuron(filename_or_stream: typing.Any) -> loaded_morphology:
     """
-    Generate a morphology/segment_tree (raw=False/True) from an SWC file following the rules prescribed by NEURON.
+    Generate a morphology from an SWC file following the rules prescribed by NEURON.
     See the documentation https://docs.arbor-sim.org/en/latest/fileformat/swc.html
     for a detailed description of the interpretation.
     """
diff --git a/python/stubs/arbor/_arbor/py.typed b/python/stubs/arbor/_arbor/py.typed
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/python/stubs/arbor/_arbor/units.pyi b/python/stubs/arbor/_arbor/units.pyi
index aa567155747f5974d9ff785e8f8bc23409ea8c40..664b21ae329d1cfd12b9afdcbee758cf05100e0a 100644
--- a/python/stubs/arbor/_arbor/units.pyi
+++ b/python/stubs/arbor/_arbor/units.pyi
@@ -166,22 +166,22 @@ mM: unit  # value = umol/L
 mS: unit  # value = mS
 mV: unit  # value = mV
 mega: unit  # value = 1000000
-micro: unit  # value = 9.99999997475242708e-07
-milli: unit  # value = 0.00100000004749745131
+micro: unit  # value = 9.99999999999999955e-07
+milli: unit  # value = 0.00100000000000000002
 mm: unit  # value = mm
 mm2: unit  # value = mm^2
 mol: unit  # value = mol
 ms: unit  # value = ms
 nA: unit  # value = nA
 nF: unit  # value = nF
-nano: unit  # value = 9.99999971718068537e-10
+nano: unit  # value = 1e-09
 nil: unit  # value =
 nm: unit  # value = nm
 nm2: unit  # value = nm^2
 ns: unit  # value = ns
 pA: unit  # value = pA
 pF: unit  # value = pF
-pico: unit  # value = 9.999999960041972e-13
+pico: unit  # value = 10e-13
 rad: unit  # value = rad
 s: unit  # value = s
 uA: unit  # value = uA
diff --git a/python/stubs/arbor/py.typed b/python/stubs/arbor/py.typed
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/python/stubs/py.typed b/python/stubs/py.typed
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/python/test/unit/test_io.py b/python/test/unit/test_io.py
index dec6be09b5edc8398168b0ccceaf5217221d246b..06098818023bbd3418d5bbbe82ca674fb958f884 100644
--- a/python/test/unit/test_io.py
+++ b/python/test/unit/test_io.py
@@ -5,7 +5,6 @@ import arbor as A
 from pathlib import Path
 from tempfile import TemporaryDirectory as TD
 from io import StringIO
-from functools import partial
 
 
 acc = """(arbor-component
@@ -153,7 +152,10 @@ class TestAccIo(unittest.TestCase):
 class TestSwcArborIo(unittest.TestCase):
     @staticmethod
     def loaders():
-        return (A.load_swc_arbor, partial(A.load_swc_arbor, raw=True))
+        return (
+            lambda f: A.load_swc_arbor(f).morphology,
+            lambda f: A.load_swc_arbor(f).segment_tree,
+        )
 
     def test_stringio(self):
         load_string(self.loaders(), swc_arbor)
@@ -171,7 +173,10 @@ class TestSwcArborIo(unittest.TestCase):
 class TestSwcNeuronIo(unittest.TestCase):
     @staticmethod
     def loaders():
-        return (A.load_swc_neuron, partial(A.load_swc_neuron, raw=True))
+        return (
+            lambda f: A.load_swc_neuron(f).morphology,
+            lambda f: A.load_swc_neuron(f).segment_tree,
+        )
 
     def test_stringio(self):
         load_string(self.loaders(), swc_neuron)
@@ -189,7 +194,10 @@ class TestSwcNeuronIo(unittest.TestCase):
 class TestAscIo(unittest.TestCase):
     @staticmethod
     def loaders():
-        return (A.load_asc, partial(A.load_asc, raw=True))
+        return (
+            lambda f: A.load_asc(f).morphology,
+            lambda f: A.load_asc(f).segment_tree,
+        )
 
     def test_stringio(self):
         load_string(self.loaders(), asc)
diff --git a/test/ubench/fvm_discretize.cpp b/test/ubench/fvm_discretize.cpp
index 7d0c6bb836ea5cdc2fa198edf2533c56bf3ba210..ce9cc719ccaa26006ffb1ccf3c4247ff72979fe1 100644
--- a/test/ubench/fvm_discretize.cpp
+++ b/test/ubench/fvm_discretize.cpp
@@ -26,7 +26,7 @@ arb::morphology from_swc(const std::string& path) {
     std::ifstream in(path);
     if (!in) throw std::runtime_error("could not open "+path);
 
-    return arborio::load_swc_arbor(arborio::parse_swc(in));
+    return arborio::load_swc_arbor(arborio::parse_swc(in)).morphology;
 }
 
 void run_cv_geom(benchmark::State& state) {
diff --git a/test/unit/test_asc.cpp b/test/unit/test_asc.cpp
index ec2cad1685e38b351e556c415bfa0b682d7c49f8..f441541b66bd9aead8ed753e7845d254990d55cb 100644
--- a/test/unit/test_asc.cpp
+++ b/test/unit/test_asc.cpp
@@ -16,7 +16,7 @@ TEST(asc, file_not_found) {
 
 // Declare the implementation of the parser that takes a string input
 namespace arborio {
-asc_morphology parse_asc_string(const char* input);
+loaded_morphology parse_asc_string(const char* input);
 }
 
 // Test different forms of empty files.
@@ -300,3 +300,68 @@ TEST(asc, soma_connection) {
         EXPECT_EQ(m.branch_segments(7)[0].prox, (arb::mpoint{0,-5, 0, 1}));
     }
 }
+
+// Soma composed of 2 branches, and a dendrite with a bit more interesting branching.
+const char *asc_w_spines_and_markers ="((CellBody)\n"
+" (0 0 0 4)\n"
+" <(11 12 13 14 S1)>\n"
+" (Cross (Color Black) (Name \"M1\") (0 0 0 1) (0 0 1 1))\n"                                        
+")\n"
+"((Dendrite)\n"
+" (0 2 0 2)\n"
+" (0 5 0 2)\n"
+" (Dot (Color Black) (Name \"M2\") (0 0 0 1))\n"
+" <(1 2 3 4 S2)>\n"
+" (\n"
+"  (-5 5 0 2)\n"
+"  (\n"
+"   (-5 5 0 2)\n"
+"   |\n"
+"   (6 5 0 2)\n"
+"  )\n"
+"  |\n"
+"  (6 5 0 2)\n"
+" )\n"
+" )";
+
+TEST(asc, spine) {
+    {
+        auto result = arborio::parse_asc_string(asc_w_spines_and_markers);
+        const auto& m = result.morphology;
+        EXPECT_EQ(m.num_branches(), 7u);
+        // Test soma
+        EXPECT_EQ(m.branch_segments(0)[0].prox, (arb::mpoint{0, 0, 0, 2}));
+        EXPECT_EQ(m.branch_segments(0)[0].dist, (arb::mpoint{0,-2, 0, 2}));
+        EXPECT_EQ(m.branch_segments(1)[0].prox, (arb::mpoint{0, 0, 0, 2}));
+        EXPECT_EQ(m.branch_segments(1)[0].dist, (arb::mpoint{0, 2, 0, 2}));
+        // Test dendrite proximal ends
+        EXPECT_EQ(m.branch_segments(2)[0].prox, (arb::mpoint{ 0, 2, 0, 1}));
+        EXPECT_EQ(m.branch_segments(3)[0].prox, (arb::mpoint{ 0, 5, 0, 1}));
+        EXPECT_EQ(m.branch_segments(4)[0].prox, (arb::mpoint{-5, 5, 0, 1}));
+        EXPECT_EQ(m.branch_segments(5)[0].prox, (arb::mpoint{-5, 5, 0, 1}));
+        EXPECT_EQ(m.branch_segments(6)[0].prox, (arb::mpoint{ 0, 5, 0, 1}));
+        // Now check metadata
+        auto d = std::get<arborio::asc_metadata>(result.metadata);
+        EXPECT_EQ(d.spines.size(), 2);
+        EXPECT_EQ(d.spines[0].location.x, 11);
+        EXPECT_EQ(d.spines[0].location.y, 12);
+        EXPECT_EQ(d.spines[0].location.z, 13);
+        EXPECT_EQ(d.spines[0].location.radius, 7);
+        EXPECT_EQ(d.spines[0].name, "S1");
+        
+        EXPECT_EQ(d.spines[1].location.x, 1);
+        EXPECT_EQ(d.spines[1].location.y, 2);
+        EXPECT_EQ(d.spines[1].location.z, 3);
+        EXPECT_EQ(d.spines[1].location.radius, 2);
+        EXPECT_EQ(d.spines[1].name, "S2");
+
+        EXPECT_EQ(d.markers.size(), 2);
+        EXPECT_EQ(d.markers[0].locations.size(), 2);
+        EXPECT_EQ(d.markers[0].name, "M1");
+        EXPECT_EQ(d.markers[0].marker, arborio::asc_marker::cross);
+
+        EXPECT_EQ(d.markers[1].locations.size(), 1);
+        EXPECT_EQ(d.markers[1].name, "M2");
+        EXPECT_EQ(d.markers[1].marker, arborio::asc_marker::dot);
+    }
+}
diff --git a/test/unit/test_morphology.cpp b/test/unit/test_morphology.cpp
index 5c305d850e108471cf09254c5d11f685cc47b3f9..2104428d726f8cdffa5e5871c564ab41f87f62ef 100644
--- a/test/unit/test_morphology.cpp
+++ b/test/unit/test_morphology.cpp
@@ -322,7 +322,8 @@ TEST(morphology, swc) {
     auto swc = arborio::parse_swc(fid);
 
     // Build a segmewnt_tree from swc samples.
-    auto m = arborio::load_swc_arbor(swc);
+    auto lm = arborio::load_swc_arbor(swc);
+    const auto& m = lm.morphology;
     EXPECT_EQ(221u, m.num_branches()); // 219 branches + 2 from divided soma.
 }
 #endif
diff --git a/test/unit/test_nml_morphology.cpp b/test/unit/test_nml_morphology.cpp
index 8c6ecfc039959a3846a4cd50c671107099fc8780..d2d0cfb27089258dd9c862a9e0016d7e74541561 100644
--- a/test/unit/test_nml_morphology.cpp
+++ b/test/unit/test_nml_morphology.cpp
@@ -78,16 +78,18 @@ R"~(
     std::sort(c_ids.begin(), c_ids.end());
     EXPECT_EQ((svector{"c3", "c4"}), c_ids);
 
-    arborio::nml_morphology_data mdata;
-
-    mdata = N.cell_morphology("c4").value();
-    EXPECT_EQ("c4", mdata.cell_id);
-    EXPECT_EQ("m4", mdata.id);
-
-    mdata = N.cell_morphology("c3").value();
-    EXPECT_EQ("c3", mdata.cell_id);
-    EXPECT_EQ("m1", mdata.id);
-
+    {
+        auto mdata = N.cell_morphology("c4").value();
+        auto meta = std::get<arborio::nml_metadata>(mdata.metadata);
+        EXPECT_EQ("c4", meta.cell_id);
+        EXPECT_EQ("m4", meta.id);
+    }
+    {
+        auto mdata = N.cell_morphology("c3").value();
+        auto meta = std::get<arborio::nml_metadata>(mdata.metadata);
+        EXPECT_EQ("c3", meta.cell_id);
+        EXPECT_EQ("m1", meta.id);
+    }
     EXPECT_THROW(N.cell_morphology("mr. bobbins").value(), std::bad_optional_access);
 }
 
@@ -166,8 +168,9 @@ R"~(
 
     {
         auto m1 = N.morphology("m1").value();
+        auto d1 = std::get<arborio::nml_metadata>(m1.metadata);
         label_dict labels;
-        labels.import(m1.segments, "seg:");
+        labels.extend(d1.segments, "seg:");
         mprovider P(m1.morphology, labels);
 
         EXPECT_TRUE(region_eq(P, reg::named("seg:0"), reg::all()));
@@ -178,9 +181,10 @@ R"~(
     }
 
     {
-        arborio::nml_morphology_data m2 = N.morphology("m2").value();
+        auto m2 = N.morphology("m2").value();
+        auto d2 = std::get<arborio::nml_metadata>(m2.metadata);
         label_dict labels;
-        labels.import(m2.segments, "seg:");
+        labels.extend(d2.segments, "seg:");
         mprovider P(m2.morphology, labels);
 
         mextent seg0_extent = thingify(reg::named("seg:0"), P);
@@ -206,9 +210,10 @@ R"~(
     }
 
     {
-        arborio::nml_morphology_data m3 = N.morphology("m3").value();
+        auto m3 = N.morphology("m3").value();
+        auto d3 = std::get<arborio::nml_metadata>(m3.metadata);
         label_dict labels;
-        labels.import(m3.segments, "seg:");
+        labels.extend(d3.segments, "seg:");
         mprovider P(m3.morphology, labels);
 
         mextent seg0_extent = thingify(reg::named("seg:0"), P);
@@ -240,9 +245,10 @@ R"~(
     }
     {
         for (const char* m_name: {"m4", "m5"}) {
-            arborio::nml_morphology_data m4_or_5 = N.morphology(m_name).value();
+            auto m4_or_5 = N.morphology(m_name).value();
+            auto d4_or_5 = std::get<arborio::nml_metadata>(m4_or_5.metadata);
             label_dict labels;
-            labels.import(m4_or_5.segments, "seg:");
+            labels.extend(d4_or_5.segments, "seg:");
             mprovider P(m4_or_5.morphology, labels);
 
             mextent seg0_extent = thingify(reg::named("seg:0"), P);
@@ -329,9 +335,10 @@ R"~(
     arborio::neuroml N(doc);
 
     {
-        arborio::nml_morphology_data m1 = N.morphology("m1", allow_spherical_root).value();
+        auto m1 = N.morphology("m1", allow_spherical_root).value();
+        auto d1 = std::get<arborio::nml_metadata>(m1.metadata);
         label_dict labels;
-        labels.import(m1.segments, "seg:");
+        labels.extend(d1.segments, "seg:");
         mprovider P(m1.morphology, labels);
 
         EXPECT_TRUE(region_eq(P, reg::branch(0), reg::all()));
@@ -358,9 +365,10 @@ R"~(
     }
     {
         // With spherical root _not_ provided, treat it just as a simple zero-length segment.
-        arborio::nml_morphology_data m1 = N.morphology("m1", none).value();
+        auto m1 = N.morphology("m1", none).value();
+        auto d1 = std::get<arborio::nml_metadata>(m1.metadata);
         label_dict labels;
-        labels.import(m1.segments, "seg:");
+        labels.extend(d1.segments, "seg:");
         mprovider P(m1.morphology, labels);
 
         EXPECT_TRUE(region_eq(P, reg::branch(0), reg::all()));
@@ -371,9 +379,10 @@ R"~(
         EXPECT_EQ(p0, G.at(mlocation{0, 1}));
     }
     {
-        arborio::nml_morphology_data m2 = N.morphology("m2", allow_spherical_root).value();
+        auto m2 = N.morphology("m2", allow_spherical_root).value();
+        auto d2 = std::get<arborio::nml_metadata>(m2.metadata);
         label_dict labels;
-        labels.import(m2.segments, "seg:");
+        labels.extend(d2.segments, "seg:");
         mprovider P(m2.morphology, labels);
 
         EXPECT_TRUE(region_eq(P, reg::branch(0), reg::all()));
@@ -387,9 +396,10 @@ R"~(
                     (p0==points[1] && p1==points[0]));
     }
     {
-        arborio::nml_morphology_data m3 = N.morphology("m3", allow_spherical_root).value();
+        auto m3 = N.morphology("m3", allow_spherical_root).value();
+        auto d3 = std::get<arborio::nml_metadata>(m3.metadata);
         label_dict labels;
-        labels.import(m3.segments, "seg:");
+        labels.extend(d3.segments, "seg:");
         mprovider P(m3.morphology, labels);
         place_pwlin G(P.morphology());
 
@@ -409,9 +419,10 @@ R"~(
         EXPECT_EQ(p2, s1d);
     }
     {
-        arborio::nml_morphology_data m4 = N.morphology("m4", allow_spherical_root).value();
+        auto m4 = N.morphology("m4", allow_spherical_root).value();
+        auto d4 = std::get<arborio::nml_metadata>(m4.metadata);
         label_dict labels;
-        labels.import(m4.segments, "seg:");
+        labels.extend(d4.segments, "seg:");
         mprovider P(m4.morphology, labels);
         place_pwlin G(P.morphology());
 
@@ -593,22 +604,16 @@ R"~(
     using reg::named;
 
     {
-        arborio::nml_morphology_data m1 = N.morphology("m1").value();
-        label_dict labels;
-        labels.import(m1.segments);
-        labels.import(m1.groups);
-        mprovider P(m1.morphology, labels);
+        auto m1 = N.morphology("m1").value();
+        mprovider P(m1.morphology, m1.labels);
 
         EXPECT_TRUE(region_eq(P, named("group-a"), named("0")));
         EXPECT_TRUE(region_eq(P, named("group-b"), named("2")));
         EXPECT_TRUE(region_eq(P, named("group-c"), join(named("2"), named("1"))));
     }
     {
-        arborio::nml_morphology_data m2 = N.morphology("m2").value();
-        label_dict labels;
-        labels.import(m2.segments);
-        labels.import(m2.groups);
-        mprovider P(m2.morphology, labels);
+        auto m2 = N.morphology("m2").value();
+        mprovider P(m2.morphology, m2.labels);
 
         EXPECT_TRUE(region_eq(P, named("group-a"), join(named("0"), named("2"))));
         EXPECT_TRUE(region_eq(P, named("group-c"), join(named("0"), named("1"), named("2"))));
@@ -762,11 +767,8 @@ R"~(
 
     arborio::neuroml N(doc);
 
-    arborio::nml_morphology_data m1 = N.morphology("m1").value();
-    label_dict labels;
-    labels.import(m1.segments);
-    labels.import(m1.groups);
-    mprovider P(m1.morphology, labels);
+    auto m1 = N.morphology("m1").value();
+    mprovider P(m1.morphology, m1.labels);
 
     // Note: paths/subTrees respect segment parent–child relationships,
     // not morphological distality.
diff --git a/test/unit/test_swcio.cpp b/test/unit/test_swcio.cpp
index dacd87805ea07a38df0f5a81f67dfdedb416d34b..918f735d7ca90ab4fe8d553014ba7e01630058d3 100644
--- a/test/unit/test_swcio.cpp
+++ b/test/unit/test_swcio.cpp
@@ -270,7 +270,8 @@ TEST(swc_parser, arbor_compliant) {
             {7, 3, p4.x, p4.y, p4.z, p4.radius, 4}
         };
 
-        auto morpho = load_swc_arbor(swc);
+        auto loaded = load_swc_arbor(swc);
+        const auto& morpho = loaded.morphology;
         ASSERT_EQ(3u, morpho.num_branches());
 
         EXPECT_EQ(mnpos, morpho.branch_parent(0));
@@ -315,7 +316,8 @@ TEST(swc_parser, arbor_compliant) {
             {4, 3, p3.x, p3.y, p3.z, p3.radius, 2},
         };
 
-        auto morpho = load_swc_arbor(swc);
+        auto loaded = load_swc_arbor(swc);
+        auto morpho = loaded.morphology;
         ASSERT_EQ(2u, morpho.num_branches());
 
         EXPECT_EQ(mnpos, morpho.branch_parent(0));
@@ -377,7 +379,9 @@ TEST(swc_parser, neuron_compliant) {
         std::vector<swc_record> swc{
             {1, 1, p0.x, p0.y, p0.z, p0.radius, -1}
         };
-        auto morpho = load_swc_neuron(swc);
+
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         mpoint prox{p0.x-p0.radius, p0.y, p0.z, p0.radius};
         mpoint dist{p0.x+p0.radius, p0.y, p0.z, p0.radius};
@@ -406,7 +410,8 @@ TEST(swc_parser, neuron_compliant) {
             {1, 1, p0.x, p0.y, p0.z, p0.radius, -1},
             {2, 1, p1.x, p1.y, p1.z, p1.radius,  1}
         };
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_arbor(swc);
+        const auto& morpho = loaded.morphology;
 
         ASSERT_EQ(1u, morpho.num_branches());
 
@@ -431,7 +436,9 @@ TEST(swc_parser, neuron_compliant) {
             {2, 1, p1.x, p1.y, p1.z, p1.radius,  1},
             {3, 1, p2.x, p2.y, p2.z, p2.radius,  2}
         };
-        auto morpho = load_swc_neuron(swc);
+
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         ASSERT_EQ(1u, morpho.num_branches());
 
@@ -466,7 +473,8 @@ TEST(swc_parser, neuron_compliant) {
             {10, 1, p4.x, p4.y, p4.z, p4.radius,  8},
             {12, 1, p5.x, p5.y, p5.z, p5.radius, 10}
         };
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         ASSERT_EQ(1u, morpho.num_branches());
 
@@ -507,7 +515,8 @@ TEST(swc_parser, neuron_compliant) {
             {2, 3, p1.x, p1.y, p1.z, p1.radius,  1},
             {3, 3, p2.x, p2.y, p2.z, p2.radius,  2}
         };
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         mpoint prox{-10, 0, 0, 10};
         mpoint dist{ 10, 0, 0, 10};
@@ -548,7 +557,8 @@ TEST(swc_parser, neuron_compliant) {
             {23, 1, p0.x, p0.y, p0.z, p0.radius, -1},
             {83, 3, p1.x, p1.y, p1.z, p1.radius, 23}
         };
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         mpoint prox{-10, 0, 0, 10};
         mpoint dist{ 10, 0, 0, 10};
@@ -592,7 +602,8 @@ TEST(swc_parser, neuron_compliant) {
             {3, 3, p2.x, p2.y, p2.z, p2.radius,  2},
             {4, 3, p3.x, p3.y, p3.z, p3.radius,  3}
         };
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         ASSERT_EQ(1u, morpho.num_branches());
 
@@ -658,7 +669,8 @@ TEST(swc_parser, neuron_compliant) {
             {18, 3, -8, 15, 0, 1, 17}
         };
 
-        auto morpho = load_swc_neuron(swc);
+        auto loaded = load_swc_neuron(swc);
+        const auto& morpho = loaded.morphology;
 
         ASSERT_EQ(10u, morpho.num_branches());
 
@@ -739,6 +751,7 @@ TEST(swc_parser, neuron_compliant) {
         EXPECT_EQ(p17, segs_9[0].dist);
     }
 }
+
 TEST(swc_parser, not_neuron_compliant) {
     using namespace arborio;
     {