Repository: abey79/vpype

Issue:
Classify lines by colour into layers (`read`)
It would be very useful to be able to sort lines by a range of colours and/or brightness into layers. My end goal would then be to use the sorted layers to apply different hatching to the shapes so that the overlaid shapes create nice shading effects. At the moment it I have not been able to find a way of selecting lines or objects with **similar** colour / brightness thresholds so that the number of layers generated could be controlled to match the number of pens or effects desired  In my case the shapes are separate closed polygons, but are layered on top of each other to achieve a final colour / shade

Updated:
[block_test.zip](https://github.com/abey79/vpype/files/4640101/block_test.zip)

read: --layer X should imply --single-layer (instead of being ignored)



Discussion:
I would also find this very helpful.
If I get it correctly, the idea would be to have a flag in `read` such that, instead of considering top-level SVG group, it would sort geometries by their styling attribute. How should attribute be pooled then? Each unique stroke colour matched to a different layer (disregarding anything else, stroke width, alpha, etc.)?

It feels tricky to do right because either you have control on how the SVG is made (and then why not just use top level groups). Or you don't. But then the possibilities are endless. Case in point: @ithinkido's file doesn't even have stroke styles (fills are styled instead). For a tool that is all about lines, it would be weird to consider fills.

A dirty hack could be to pool by unique content of `style` attribute (completely disregarding its actually content, nor the styling inherited from parent elements). This would work with that file, and possibly other situation, but it's really dirty and will fail in other situation (e.g inherited style, etc.)
I was imagining something like `read group -- <option> --<threshold>`
Option would be a singular attribute and could include things  like colour, brightness or style as these would be the most used values in plotting, and a treshold to control how many groups are outputed. In my case disregarding stroke width , alpha etc would not be and issue, and I suspect the majority of users would continue to modify the produced layers to suit thier needs 
 I agree that my example file is not the best test candidate, but it helps to visualy understand the idea of overlaid lines or shapes that become difficult to group, I only see now that there is no stroke style assigned which is not ideal for a test case, but I think the idea is clear enough. 
It would be useful to have some specific examples of workflows where using SVG's `<g>` in the first place is not an option.
Not quite sure I understand "...  using SVG's in the first place is not an option."

I have updated my test file , the fill and stroke attributes are now both included. 
My specific workflow in this case would be to separate out the shapes using their stroke attributes into groups of similar lightness ( these could be put into layers ) . Once that would be done , I would later add hatching to the shapes based on the lightness.
What I can also imagine is that others who would generate multi colour plots might like to confine the number of different pens used by controlled grouping of lines based on their colour attributes to layers.

There are two general selection tool ideas that I have found so far. Inkscape can select objects with identical attributes, Gimp has a magic wand function that can select by a range of attributes defined by thresholds but can not differentiate if objects are overlaid to create a new colour.
The ideal would be a combination with the ability to select lines based on their attributes within a threshold. This could be used when plotting with different colours to achieve a combined colour effect. In the simplest form - Blue box over Yellow box = Green box.
My plotter has 8 colours, Axi has theoretically unlimited, but realistically most users would be trying to use a limited number of pen options, so controlling grouping of the lines to match how many pens you have or want to use is the goal. 
 
> Not quite sure I understand "... using SVG's in the first place is not an option."

Sorry, the `<g>` disappeared because of formatting.

What I fail to understand: if you generate the SVG yourself and are able to add arbitrary formatting to your SVG, why can't you directly group them in the SVG and use the existing layer support?
I do not have the control over the generated svg in this case, and I have not been able to find a way of grouping **similar** ( not only identical) objects or lines or colours.
> I do not have the control over the generated svg in this case

How do you set/change styling attributes then?
There is an inkscape extension to add fill attributes to stroke. 
You need to store the color of each individual layer. So that all layers have a color. This is nothing to do with -read that's overloading that command with stuff it doesn't need to do, but only it can do because only it has access to the colors.

If each layer has a color, and some metadata just a dictionary of some string stuff that gets ignored by like 99% of everything. In fact if you wanted you could dump the .values of the svg shape you read into there, maybe something down the line would care you set teddy_bear="True",  on your path even though it's generally meaningless to basically everything (though fill='red') might come in handy..

In any event, you'd have a color on the layers. And you'd have a command to sort those layers. Tada! If you wanted to sort them based on the how similar they are to a defined color, you could easily test the color distance of from one color to the next color.

The nearest color in those layers get sorted first:
 lsort --color "black" --ascending

The svgelements code includes a really helpful function for this https://github.com/meerk40t/svgelements/blob/a30578b136f2c06dfdef0a39e2870de07e427b53/svgelements/svgelements.py#L1623 Basically if you define any two colors in svgelements you can request the distance. You also can get the distance_sq which is fine since it'll sort the same. There's also a bunch of properties like `lightness`, `saturation`, `intensity`, `brightness`, `hue`, `blackness`, `luminance`, `luma` and obvious ones like green, blue, red, and alpha. There's subtle differences between many those values in color theory. 

There'd also be obvious sorting criteria like number of points, and stroke/pen-width.
Some of this may be residual prior to svgelements when svg was a bigger and more daunting thing and you'd need to be more strict. But, really a grab bag of values() to shove all your metadata that just generally gets ignored, and some properties with defined types like your stroke, and pen_width defined on the layers and you just add in your lsort command and you're good.
As you suggest, adding a bunch of per layer metadata in `vpype.Document` would be no big deal, be it structured (`color`, `penWidth`, etc.) or not (`values` map). Further, as initially requested in the present issue, `read` would have to offer a choice between structuring layers by color in addition to top-level <g> (as is currently the case). In this context, colour "closeness" could be useful.

Obvious consequences:
- `show` would use layer colours when colouring by layer (per path colouring is useful eg. to check `linemerge` results
- `write` would use layer colours for export
- a new `color` command should be added to manually set a layer's colour
- `write --layer-label` should have a formatter for colour

Open questions:
- What's the default colour for a layer created by anything else than read? (e.g. `vpype circle 0 0 10 write output.svg`)
- Why only colour? Stroke width could also be defendable criterion for layer sorting. Are there others? We'd need to make sure the complexity remains in check.
- What would be a repeatable way to map colours to layer ID, in a way that?
- What's a suitable CLI UX to deal with colours (`color` and `write --layer-label`)?

* The default color of the primaries would be None. That is their current color. All other code should do with what they currently do. Unless that color is set to something in which case the functionality is overridden. If they want to specify something else they'd use a --color option or something. The other alternatives "black" gives them information they currently do not possess namely you couldn't tell black from default black which leads to random output in show.  The other other alternative is directly random from the start, this is also an issue since it's conveying on the layer information they didn't already have, and you can't tell this random from a `none` which would result in random. 

* You can sort by anything. You might even be able to shorthand a string that does sorting `<color` or `>color`. `<red`. `<luma` `>pen_width`, `>pen_width,<color` to sort ascending by pen width but subsort by color within that. 
     * I think the complexity is only being increased by making read and write a sort of god-object since they are the only function with access to the initial shapes. And if I wanted to add in dxf reading and writing I'd basically be duplicating like 90% of the SVG functionality, and all the load commands. And then making a sort of strange `dread` `dwrite` functions to do that. The thing about the suggestion here is that we are sorting in *another* command. We aren't sorting in the read function because only the read function knows what colors things are. You can sort based on whatever and have different lsort plugins as needed. 
* In theory the colors would be part of the layer that has colors. They would get their colors from their existence within that layer. If you moved them they wouldn't have that color anymore.  They'd have LineCollections with colors, the blocks within that wouldn't map to anything.
* You could have a lot of other commands that work on the `vp.ColorType` like basic commands in `darken` or `desaturate` or `assign_real_random_colors` (bad name).


> I think the complexity is only being increased by making read and write a sort of god-object since they are the only function with access to the initial shapes .[...] We aren't sorting in the read function because only the read function knows what colors things are. You can sort based on whatever and have different lsort plugins as needed.

This is both a very insightful comment, and contradicts the premises that colour (and other metadata) is attached to layer as opposed to individual paths.

I now see two approaches.

#### 1. Metadata is attached at layer-level.

This is what I had initially in mind.

Pros:
- Much lower impact on the existing code-base (metadata added to `vp.Document`, can be ignored by most code so long a new doc are create with `doc.empty_copy()` to preserve said metadata (already happening now for page size).
- No impact on `vp.LineCollection()`.

Cons:
- This doesn't capture the depth of, eg. SVG's possibilities, so the process is lossy.
- Metadata-based layer grouping *must* append in `read`, making its interface and implementation more complex.

#### 2. Meta data is attached at path-level

Pros:
- This much simplifies the implementation of `read` (just attach meta data to each path and stick to the "standard" `<g>`-based layering).
- Metadata-based layer grouping can be implemented in a separate `lsort` command whose implementation and UX would be relatively easy to come up with.

Cons:
- Very significant changes in `vp.LineCollection` with widespread impacts throughout the codebase (including third-party projects).
- Adds complexity to all generators (primitives, etc.) with metadata-related UX.
- Layer-based meta data would still be required to be used as default for e.g. primitives.

---

Although the second approach's pros are very attractive, I don't think we should pursue this approach for the time being. The main reason is that the problem we are trying to solve (namely match SVG's path color to specific plotter's pen) is relatively niche. In 90% of the case, this is dealt with by the grouping structure of the SVG, and the first approach would be OK in the remaining cases. From this perspective, the widespread impact on both UX and code-base isn't warranted. Much of the success of vpype has to do with the simplicity of its data model, and I'm reluctant to make it more complex unless there are strong reasons to do so.


I was and am advocating for 1. If you opted for 2 it simplifies things while complicating them. You don't need groups at all if you opted for 2. You could flat file the entire thing and have metadata laden lines.

I don't see what the point of the LineCollection is if not to hold the metadata about the lines they contain. The sort would not necessarily result in the same number of layers. You would sort everything as a flat file and group them according to adjacent metadata. So if you had 10 line blocks in 3 layers and you sorted them based on their pen width or their color you'd get the same 3 layers sorted based on that criteria since it wouldn't change anything.

However, if you, however sorted based on the number of points, you could end up with 30 different layers, with 1 line block each, since the layers would be broken up. You'd have layers with identical metadata stacked such that they preserve their order, at the cost of dividing up the layers. 

I don't think the lines should be complicated. I think all metadata goes to the layers, with some of it could be called as a type. In elements I parse a few smaller things like fill and stroke and transform and put those as solved values on the object but, when most things get tossed into values. If you wanted them you could fiddle around in there and get out any dash array that might exist or whatever, but it certainly wouldn't be required.

1. Cons
* This doesn't capture the depth of, eg. SVG's possibilities, so the process is lossy.
     * The process is *already* lossy. And it's likely a good thing. You're not dealing with shapes etc. And you're getting better results out of constraining the types. So you have documents which are individual files. They might have some file level metadata what the original file was, what the command being issued was, whether this has already been loaded or not, etc. And layer level metadata that gives specifics that are true for all the objects it contains. Most importantly pen-width and color. Then you have your lines which are simple pointlist structures that merely contain a list of points in sequence. You can ensure everything using these classes would have at most 4 levels. Assuming you have pipeline level view of multiple documents, document level objects, layer level objects, and lines. And pipeline isn't really a thing, I just sort of want it to be.  Everything is well defined and gives it a proper level. SVG allows metadata on everything, lines, layers, documents, this sort of limited nested hierarchy tends to capture a lot of different file types.

* Metadata-based layer grouping must append in read, making its interface and implementation more complex.
    * You could make 1 layer for 1 object and be kinda simple that way. SVG lets any number of levels exist, embroidery is a series of commands one of which tends to be change the thread and the series of points and colors that way is a sort of increased hierarchy.  You don't need to do the harder or weirder grouping. 

Pros:
* This much simplifies the implementation of read (just attach meta data to each path and stick to the "standard" <g>-based layering).
     * SVG allows a lot of stuff to be put on groups and nested groups all the properties are configurable at each layer. Svgelements just gives you the list of relevant values because it's much easier than parsing all that stuff yourself. You could still just put a path in a group and flag that group with the metadata you would have put on the group. The only reasons this would matter is joining groups together based on their colors and pen_width.
* Metadata-based layer grouping can be implemented in a separate lsort command whose implementation and UX would be relatively easy to come up with
     * You could sort at the layer level too, you'd just need to split and combine layers more than you currently do. The same is true for the line-block level one too. You'd just basically treat all objects flat and then make copies of their groups if they end up splitting, etc. 
Oh, I think there is a big misunderstanding here, which is now obvious from the bad wording of this issue's title.

OP's goal is not to reorder existing layers according to some criterion (which would indeed be very easy as soon as some metadata is attached to layers), but rather to **create** layers (and distribute paths within them) according to some criterion. For example, for a `read` on a SVG with some red paths, some blue paths and some black paths, *regardless of how they are structured in the SVG*, all red paths should end up in layer 1, all blue paths should end up in layer 2 and all black paths should end up in layer 3. That would have to happen in `read`, or would require strategy (2) above. Again, desirable feature would be to group similar colour in the same layer according to some threshold, or use entirely different criterion like stroke width, etc. Or combination thereof. 
You could absolutely do that in 1. You would load all the metadata layers and anytime you have a change in the metadata along the color or pen_width/stroke_width value you would create a new layer and put that pointseries in that different layer with different metadata. Making layers as needed. Then you might well create 10 layers or so with each one containing a couple different pointseries objects. You then sort that based on the color, which would give you 3 different layers and only break the initially loaded ordering when specified specifically to do so. And not in the read command itself.

The program I wrote that does the embroidery drawing works exactly like 1. It has ColorLayers and PointLayers (pardon the camelcase, it's Java). And will organize things into these the layers. If a Pointlayer is moved from one color to another, it'll change color. But the order is given exactly in depth first order. So the first PointLayer in the first ColorLayer is first. And if you need to change that color, you would add in another ColorLayer in the 0th position and insert that point within that ColorLayer. It's not too hard to track things, you can recolor entire sets of lines in a single command, and changing the color of a single line layer would just bifircate the color layer and insert another one, between them. And you can also rather easily merge these if that is needed. And *most* of that wouldn't be needed. All you actually need is to give layers colors and pen_width, and maybe do some stuff to help reorganizing the layers if you're going doing something fancy but those don't usually come and it's not hard when they do.
> Metadata-based layer grouping must append in read, making its interface and implementation more complex.

I misread that the first time, yeah, you couldn't just dump everything in the same line collection, you'd make a series of line collections in a read operation each one classified based on their color and pen_width already. Since that's how they'd have that information. If you wanted you could do the current method and put them all into a single LineCollection with None for pen_width and color. You would need to do something slightly different than throwing all the colors and and pen_widths out the window.
Work-around possible with the following inkscape plug-in:
https://wiki.fablabchemnitz.de/display/IFM/Styles+To+Layers


Resolving patch:
diff --git a/vpype/io.py b/vpype/io.py
--- a/vpype/io.py
+++ b/vpype/io.py
@@ -1,10 +1,12 @@
 """File import/export functions.
 """
+import collections
 import copy
+import dataclasses
 import datetime
 import math
 import re
-from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, Union, cast
+from typing import Any, Dict, Iterable, Iterator, List, Optional, TextIO, Tuple, Union, cast
 from xml.etree import ElementTree
 
 import click
@@ -29,7 +31,13 @@
 from .model import Document, LineCollection
 from .utils import UNITS
 
-__all__ = ["read_svg", "read_multilayer_svg", "write_svg", "write_hpgl"]
+__all__ = [
+    "read_svg",
+    "read_multilayer_svg",
+    "read_svg_by_attributes",
+    "write_svg",
+    "write_hpgl",
+]
 
 
 _DEFAULT_WIDTH = 1000
@@ -75,14 +83,13 @@ def get(self) -> np.ndarray:
         return self._stack
 
 
-_PathListType = List[
-    Union[
-        # for actual paths and shapes transformed into paths
-        svgelements.Path,
-        # for the special case of Polygon and Polylines
-        List[Union[svgelements.PathSegment, svgelements.Polygon, svgelements.Polyline]],
-    ]
+_PathType = Union[
+    # for actual paths and shapes transformed into paths
+    svgelements.Path,
+    # for the special case of Polygon and Polylines
+    List[Union[svgelements.PathSegment, svgelements.Polygon, svgelements.Polyline]],
 ]
+_PathListType = List[_PathType]
 
 
 _NAMESPACED_PROPERTY_RE = re.compile(r"{([-a-zA-Z0-9@:%._+~#=/]+)}([a-zA-Z0-9]+)")
@@ -145,9 +152,50 @@ def _intersect_dict(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
     return dict(a.items() & b.items())
 
 
+def _merge_metadata(
+    base_metadata: Optional[Dict[str, Any]], additional_metadata: Dict[str, Any]
+) -> Dict[str, Any]:
+    """Merge two metadata dictionaries with handling of uninitialized base dictionary."""
+
+    if base_metadata is None:
+        base_metadata = additional_metadata
+    else:
+        base_metadata = _intersect_dict(base_metadata, additional_metadata)
+    return base_metadata
+
+
+def _element_to_paths(elem: svgelements.SVGElement) -> Optional[_PathType]:
+    """Convert a SVG element into a path object that can be processed by
+    :func:`_flattened_paths_to_line_collection`
+
+    Args:
+        elem: the element to convert
+
+    Returns:
+        the path object or None if the element should be ignored
+    """
+    if isinstance(elem, svgelements.Path):
+        if len(elem) != 0:
+            return elem
+    elif isinstance(elem, (svgelements.Polyline, svgelements.Polygon)):
+        # Here we add a "fake" path containing just the Polyline/Polygon,
+        # to be treated specifically by _convert_flattened_paths.
+        path = [svgelements.Move(elem.points[0]), elem]
+        if isinstance(elem, svgelements.Polygon):
+            path.append(svgelements.Close(elem.points[-1], elem.points[0]))
+        return path
+    elif isinstance(elem, svgelements.Shape):
+        e = svgelements.Path(elem)
+        e.reify()  # In some cases the shape could not have reified, the path must.
+        if len(e) != 0:
+            return e
+
+    return None
+
+
 def _extract_paths(
     group: svgelements.Group, recursive
-) -> Tuple[_PathListType, Optional[Dict[str, str]]]:
+) -> Tuple[_PathListType, Optional[Dict[str, Any]]]:
     """Extract everything from the provided SVG group."""
 
     if recursive:
@@ -155,7 +203,7 @@ def _extract_paths(
     else:
         everything = group
     paths = []
-    metadata: Optional[Dict[str, str]] = None
+    metadata: Optional[Dict[str, Any]] = None
     for elem in everything:
         if hasattr(elem, "values") and elem.values.get("visibility", "") in (
             "hidden",
@@ -163,41 +211,72 @@ def _extract_paths(
         ):
             continue
 
-        if isinstance(elem, svgelements.Path):
-            if len(elem) != 0:
-                paths.append(elem)
-        elif isinstance(elem, (svgelements.Polyline, svgelements.Polygon)):
-            # Here we add a "fake" path containing just the Polyline/Polygon,
-            # to be treated specifically by _convert_flattened_paths.
-            path = [svgelements.Move(elem.points[0]), elem]
-            if isinstance(elem, svgelements.Polygon):
-                path.append(svgelements.Close(elem.points[-1], elem.points[0]))
-            paths.append(path)
-        elif isinstance(elem, svgelements.Shape):
-            e = svgelements.Path(elem)
-            e.reify()  # In some cases the shape could not have reified, the path must.
-            if len(e) != 0:
-                paths.append(e)
-        else:
+        path = _element_to_paths(elem)
+        if path is None:
             continue
+        else:
+            paths.append(path)
 
         # apply union on metadata
-        if metadata is None:
-            metadata = _extract_metadata_from_element(elem)
-        else:
-            metadata = _intersect_dict(metadata, _extract_metadata_from_element(elem))
+        metadata = _merge_metadata(metadata, _extract_metadata_from_element(elem))
 
     return paths, metadata
 
 
-def _convert_flattened_paths(
-    group: svgelements.Group,
-    recursive: bool,
+def _extract_paths_by_attributes(
+    group: svgelements.Group, attributes: Iterable[str]
+) -> List[Tuple[_PathListType, Optional[Dict[str, Any]]]]:
+    """Extract everything from the provided SVG group, grouped by the specified attributes.
+
+    The paths are grouped by unique combinations of the provided attributes.
+
+    Args:
+        group: SVG group from which to extract paths
+        attributes: attributes by which to group paths
+
+    Returns:
+        list of tuple containing the list of paths and the associated metadata
+    """
+
+    @dataclasses.dataclass
+    class _LayerDesc:
+        paths: _PathListType = dataclasses.field(default_factory=list)
+        metadata: Optional[Dict[str, Any]] = None
+
+    attributes = tuple(attributes)
+    results: Dict[Tuple, _LayerDesc] = collections.defaultdict(lambda: _LayerDesc())
+
+    for elem in group.select():
+        if hasattr(elem, "values") and elem.values.get("visibility", "") in (
+            "hidden",
+            "collapse",
+        ):
+            continue
+
+        key = tuple(elem.values.get(attr, None) for attr in attributes)
+
+        path = _element_to_paths(elem)
+        if path is None:
+            continue
+        else:
+            results[key].paths.append(path)
+
+        # apply union on metadata
+        results[key].metadata = _merge_metadata(
+            results[key].metadata, _extract_metadata_from_element(elem)
+        )
+
+    return [(desc.paths, desc.metadata) for desc in results.values()]
+
+
+def _flattened_paths_to_line_collection(
+    paths: _PathListType,
     quantization: float,
     simplify: bool,
     parallel: bool,
+    metadata: Optional[Dict[str, Any]] = None,
 ) -> LineCollection:
-    """Convert a list of SVG group to a :class:`LineCollection`.
+    """Convert a path list to a :class:`LineCollection`.
 
     The resulting :class:`vpype.LineCollection` instance's metadata contains all properties
     (as extracted with :func:`_extract_metadata_from_element`) whose value is identical for
@@ -207,18 +286,18 @@ def _convert_flattened_paths(
     propagated to enclosed elements by svgelements.
 
     Args:
-        group: SVG group to process
-        recursive: defines whether the group should be parsed recursively or not
+        paths: paths to process
         quantization: maximum length of linear elements to approximate curve paths
         simplify: should Shapely's simplify be run on curved elements after quantization
         parallel: enable multiprocessing
+        metadata: if provided, metadata to include in the returned
+            :class:`vpype.LineCollection` instance
 
     Returns:
-        new :class:`LineCollection` instance containing the converted geometries
+        new :class:`LineCollection` instance containing the converted geometries and the
+        provided metadata
     """
 
-    paths, metadata = _extract_paths(group, recursive)
-
     def _process_path(path):
         if len(path) == 0:
             return []
@@ -316,7 +395,8 @@ def read_svg(
 
     # default width is for SVG with % width/height
     svg = svgelements.SVG.parse(file, width=default_width, height=default_height)
-    lc = _convert_flattened_paths(svg, True, quantization, simplify, parallel)
+    paths, metadata = _extract_paths(svg, True)
+    lc = _flattened_paths_to_line_collection(paths, quantization, simplify, parallel, metadata)
 
     if crop:
         lc.crop(0, 0, svg.width, svg.height)
@@ -368,7 +448,8 @@ def read_multilayer_svg(
     document = Document(metadata=_extract_metadata_from_element(svg, False))
 
     # non-group top level elements are loaded in layer 1
-    lc = _convert_flattened_paths(svg, False, quantization, simplify, parallel)
+    paths, metadata = _extract_paths(svg, False)
+    lc = _flattened_paths_to_line_collection(paths, quantization, simplify, parallel, metadata)
     if not lc.is_empty():
         document.add(lc, 1)
         document.layers[1].metadata = lc.metadata
@@ -393,7 +474,10 @@ def _find_groups(group: svgelements.Group) -> Iterator[svgelements.Group]:
         else:
             lid = i + 1
 
-        lc = _convert_flattened_paths(g, True, quantization, simplify, parallel)
+        paths, metadata = _extract_paths(g, True)
+        lc = _flattened_paths_to_line_collection(
+            paths, quantization, simplify, parallel, metadata
+        )
 
         if not lc.is_empty():
             # deal with the case of layer 1, which may already be initialized with top-level
@@ -420,6 +504,65 @@ def _find_groups(group: svgelements.Group) -> Iterator[svgelements.Group]:
     return document
 
 
+def read_svg_by_attributes(
+    file: Union[str, TextIO],
+    attributes: Iterable[str],
+    quantization: float,
+    crop: bool = True,
+    simplify: bool = False,
+    parallel: bool = False,
+    default_width: float = _DEFAULT_WIDTH,
+    default_height: float = _DEFAULT_HEIGHT,
+) -> "Document":
+    """Read a SVG file by sorting geometries by unique combination of provided attributes.
+
+    All curved geometries are chopped in segments no longer than the value of *quantization*.
+    Optionally, the geometries are simplified using Shapely, using the value of *quantization*
+    as tolerance.
+
+    Args:
+        file: path of the SVG file or stream object
+        attributes: attributes by which the object should be sorted
+        quantization: maximum size of segment used to approximate curved geometries
+        crop: crop the geometries to the SVG boundaries
+        simplify: run Shapely's simplify on loaded geometry
+        parallel: enable multiprocessing (only recommended for ``simplify=True`` and SVG with
+            many curves)
+        default_width: default width if not provided by SVG or if a percent width is provided
+        default_height: default height if not provided by SVG or if a percent height is
+            provided
+
+    Returns:
+         :class:`Document` instance with the imported geometries and its page size set the the
+         SVG dimensions
+    """
+
+    svg = svgelements.SVG.parse(file, width=default_width, height=default_height)
+    document = Document(metadata=_extract_metadata_from_element(svg, False))
+
+    for paths, metadata in _extract_paths_by_attributes(svg, attributes):
+        lc = _flattened_paths_to_line_collection(
+            paths, quantization, simplify, parallel, metadata
+        )
+
+        if not lc.is_empty():
+            lid = document.free_id()
+            document.add(lc, lid)
+            document.layers[lid].metadata = lc.metadata
+
+    document.page_size = (svg.width, svg.height)
+
+    if crop:
+        document.crop(0, 0, svg.width, svg.height)
+
+    # Because of how svgelements works, all the <svg> level metadata is propagated to every
+    # nested tag. As a result, we need to subtract global properties from the layer ones.
+    for layer in document.layers.values():
+        layer.metadata = dict(layer.metadata.items() - document.metadata.items())
+
+    return document
+
+
 _WRITE_SVG_RESTORE_EXCLUDE_LIST = (
     "svg:display",
     "svg:visibility",
diff --git a/vpype_cli/cli.py b/vpype_cli/cli.py
--- a/vpype_cli/cli.py
+++ b/vpype_cli/cli.py
@@ -346,7 +346,9 @@ def preprocess_argument_list(args: List[str], cwd: Union[str, None] = None) -> L
     return result
 
 
-def execute(pipeline: str, document: Optional[vp.Document] = None) -> vp.Document:
+def execute(
+    pipeline: str, document: Optional[vp.Document] = None, global_opt: str = ""
+) -> vp.Document:
     """Execute a vpype pipeline.
 
     This function serves as a Python API to vpype's pipeline. It can be used from a regular
@@ -373,6 +375,7 @@ def execute(pipeline: str, document: Optional[vp.Document] = None) -> vp.Documen
     Args:
         pipeline: vpype pipeline as would be used with ``vpype`` CLI
         document: if provided, is perloaded in the pipeline before the first command executes
+        global_opt: global CLI option (e.g. "--verbose")
 
     Returns:
         pipeline's content after the last command executes
@@ -394,6 +397,8 @@ def vsketchoutput(doc):
         out_doc.extend(doc)
         return doc
 
-    args = ("vsketchinput " if document else "") + pipeline + " vsketchoutput"
+    args = " ".join(
+        [global_opt, ("vsketchinput " if document else ""), pipeline, "vsketchoutput"]
+    )
     cli.main(prog_name="vpype", args=shlex.split(args), standalone_mode=False)
     return out_doc
diff --git a/vpype_cli/read.py b/vpype_cli/read.py
--- a/vpype_cli/read.py
+++ b/vpype_cli/read.py
@@ -1,19 +1,10 @@
 import logging
 import sys
-from typing import Optional, Tuple
+from typing import List, Optional, Tuple
 
 import click
 
-from vpype import (
-    Document,
-    LayerType,
-    LengthType,
-    PageSizeType,
-    global_processor,
-    read_multilayer_svg,
-    read_svg,
-    single_to_layer_id,
-)
+import vpype as vp
 
 from .cli import cli
 
@@ -26,13 +17,20 @@
 @click.option(
     "-l",
     "--layer",
-    type=LayerType(accept_new=True),
+    type=vp.LayerType(accept_new=True),
     help="Target layer or 'new' (single layer mode only).",
 )
+@click.option(
+    "-a",
+    "--attr",
+    type=str,
+    multiple=True,
+    help="Attribute by which geometries should be grouped",
+)
 @click.option(
     "-q",
     "--quantization",
-    type=LengthType(),
+    type=vp.LengthType(),
     default="0.1mm",
     help="Maximum length of segments approximating curved elements (default: 0.1mm).",
 )
@@ -60,7 +58,7 @@
 @click.option(
     "-ds",
     "--display-size",
-    type=PageSizeType(),
+    type=vp.PageSizeType(),
     default="a4",
     help=(
         "Display size to use for SVG with width/height expressed as percentage or missing "
@@ -74,19 +72,20 @@
     default=False,
     help="Use landscape orientation ofr display size.",
 )
-@global_processor
+@vp.global_processor
 def read(
-    document: Document,
+    document: vp.Document,
     file,
     single_layer: bool,
     layer: Optional[int],
+    attr: List[str],
     quantization: float,
     simplify: bool,
     parallel: bool,
     no_crop: bool,
     display_size: Tuple[float, float],
     display_landscape: bool,
-) -> Document:
+) -> vp.Document:
     """Extract geometries from a SVG file.
 
     FILE may be a file path path or a dash (-) to read from the standard input instead.
@@ -107,9 +106,15 @@ def read(
         - If both previous steps fail, the target layer matches the top-level group's order \
 of appearance.
 
-    Using `--single-layer`, the `read` command operates in single-layer mode. In this mode, \
-all geometries are in a single layer regardless of the group structure. The current target \
-layer is used default and can be specified with the `--layer` option.
+    Alternatively, geometries may be sorted into layers based on their attributes, such as
+    color or stroke width. This is enabled by using the `--attr` option with the attribute
+    to be considered. Multiple `--attr` options may be passed with different attributes. In
+    this case, layers will be created for each unique combination of the provided attributes.
+
+    Using `--single-layer`, the `read` command operates in single-layer mode. In this mode,
+    all geometries are in a single layer regardless of the group structure. The current target
+    layer is used default and can be specified with the `--layer` option. If the `--layer`
+    option is used, `--single-layer` is assumed even if not explicitly provided.
 
     This command only extracts path elements as well as primitives (rectangles, ellipses,
     lines, polylines, polygons). Other elements such as text and bitmap images are discarded,
@@ -146,10 +151,18 @@ def read(
 
     Examples:
 
-        Multi-layer import:
+        Multi-layer SVG import:
 
             vpype read input_file.svg [...]
 
+        Import SVG, sorting geometries by stroke color:
+
+            vpype read --attr stroke input_file.svg [...]
+
+        Import SVG, sorting geometries by stroke color and width:
+
+            vpype read --attr stroke --attr stroke-width input_file.svg [...]
+
         Single-layer import:
 
             vpype read --single-layer input_file.svg [...]
@@ -174,8 +187,15 @@ def read(
     if file == "-":
         file = sys.stdin
 
+    if layer is not None and not single_layer:
+        single_layer = True
+        logging.info("read: `--layer` provided, assuming single-layer mode")
+
     if single_layer:
-        lc, width, height = read_svg(
+        if len(attr) > 0:
+            logging.warning("read: `--attr` is ignored in single-layer mode")
+
+        lc, width, height = vp.read_svg(
             file,
             quantization=quantization,
             crop=not no_crop,
@@ -185,13 +205,11 @@ def read(
             default_height=height,
         )
 
-        document.add(lc, single_to_layer_id(layer, document))
+        document.add(lc, vp.single_to_layer_id(layer, document))
         document.extend_page_size((width, height))
     else:
-        if layer is not None:
-            logging.warning("read: target layer is ignored in multi-layer mode")
-        document.extend(
-            read_multilayer_svg(
+        if len(attr) == 0:
+            doc = vp.read_multilayer_svg(
                 file,
                 quantization=quantization,
                 crop=not no_crop,
@@ -200,6 +218,17 @@ def read(
                 default_width=width,
                 default_height=height,
             )
-        )
+        else:
+            doc = vp.read_svg_by_attributes(
+                file,
+                attributes=attr,
+                quantization=quantization,
+                crop=not no_crop,
+                simplify=simplify,
+                parallel=parallel,
+                default_width=width,
+                default_height=height,
+            )
+        document.extend(doc)
 
     return document


Test patch:
diff --git a/tests/data/test_svg/misc/multilayer_by_attributes.svg b/tests/data/test_svg/misc/multilayer_by_attributes.svg
new file mode 100644
--- /dev/null
+++ b/tests/data/test_svg/misc/multilayer_by_attributes.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" baseProfile="tiny" height="15.875cm" version="1.2" viewBox="0 0 600.0 600.0" width="15.875cm" stroke-width="4">
+  <metadata>
+    <rdf:RDF>
+      <cc:Work>
+        <dc:format>image/svg+xml</dc:format>
+        <dc:source>vpype rect 10 10 200 200 rect -l 2 400 400 200 300 write -p 600x600 tests/data/test_svg/benchmark/multilayer.svg
+</dc:source>
+        <dc:date>2020-11-24T15:32:35.445811</dc:date>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs/>
+  <line stroke="#906" x1="300" y1="20" x2="420" y2="35" stroke-width="1"/>
+  <g fill="none" id="layer1" inkscape:groupmode="layer" inkscape:label="3" stroke="#00f" style="display:inline">
+    <polygon points="10.0,10.0 10.0,210.0 210.0,210.0 210.0,10.0"/>
+  </g>
+  <g fill="none" id="layer2" inkscape:groupmode="layer" inkscape:label="2" stroke="#080" style="display:inline">
+    <polygon points="400.0,400.0 400.0,700.0 600.0,700.0 600.0,400.0" stroke="#00f"/>
+    <g transform="translate(-50, -50) rotate(35, 400, 200)" stroke="#906">
+      <polygon points="400.0,400.0 400.0,700.0 600.0,700.0 600.0,400.0"/>
+    </g>
+  </g>
+</svg>
diff --git a/tests/test_commands.py b/tests/test_commands.py
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -35,6 +35,7 @@ class Command:
     Command("ellipse 0 0 2 4"),
     Command(f"read '{EXAMPLE_SVG}'", preserves_metadata=False),
     Command(f"read -m '{EXAMPLE_SVG}'", preserves_metadata=False),
+    Command(f"read -a stroke '{EXAMPLE_SVG}'", preserves_metadata=False),
     Command("write -f svg -"),
     Command("write -f hpgl -d hp7475a -p a4 -"),
     Command("rotate 0"),
diff --git a/tests/test_files.py b/tests/test_files.py
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -2,11 +2,13 @@
 import difflib
 import os
 import re
+from typing import Set
 
 import numpy as np
 import pytest
 
 import vpype as vp
+import vpype_cli
 from vpype_cli import DebugData, cli
 
 from .utils import TEST_FILE_DIRECTORY
@@ -367,3 +369,36 @@ def test_read_layer_name(runner):
 layer 3 property vp:name: (str) my layer 3
 """
     )
+
+
+def test_read_by_attribute():
+    def _prop_set(document: vp.Document, prop: str) -> Set:
+        return {layer.property(prop) for layer in document.layers.values()}
+
+    file = TEST_FILE_DIRECTORY / "misc" / "multilayer_by_attributes.svg"
+    doc = vp.read_svg_by_attributes(str(file), ["stroke"], 0.1)
+    assert len(doc.layers) == 2
+    assert _prop_set(doc, "vp:color") == {vp.Color("#906"), vp.Color("#00f")}
+
+    doc = vp.read_svg_by_attributes(str(file), ["stroke", "stroke-width"], 0.1)
+    assert len(doc.layers) == 3
+    assert _prop_set(doc, "vp:color") == {vp.Color("#906"), vp.Color("#00f")}
+    assert _prop_set(doc, "vp:pen_width") == pytest.approx({1, 4})
+
+
+def test_read_layer_assumes_single_layer(runner, caplog):
+    test_file = TEST_FILE_DIRECTORY / "misc" / "multilayer.svg"
+    doc = vpype_cli.execute(f"read --layer 2 '{test_file}'", global_opt="-v")
+
+    assert "assuming single-layer mode" in caplog.text
+    assert len(doc.layers) == 1
+    assert 2 in doc.layers
+
+
+def test_read_single_layer_attr_warning(runner, caplog):
+    test_file = TEST_FILE_DIRECTORY / "misc" / "multilayer_by_attributes.svg"
+    doc = vpype_cli.execute(f"read -m -a stroke '{test_file}'")
+
+    assert "`--attr` is ignored in single-layer mode" in caplog.text
+    assert len(doc.layers) == 1
+    assert 1 in doc.layers