9

Given a table with line geometry, and one or more points that are snapped to this line in a separate table, I would like to split each line with one or more intersecting points at each of the locations where the line intersects a point.

For example, there is a line, L, with three intersecting points, A, B, and C in order along the line geometry. I would like to return L as four distinct geometries: from the beginning of L to A, from A to B along L, from B to C along L, and from C to the end of L.

In the past I have used shapely for this task, which is a linear referencing problem (http://sgillies.net/blog/1040/shapely-recipes/). However, this would not be practicable in this case, which has many millions of lines and points. Instead, I'm looking for a solution using PostgreSQL/PostGIS.

Note that points are constrained to be on a line. Further, a point can validly be on the start or end of a line, in which case the line need not be split (unless there are other points which are not coincident with the same line's start or end points). The subset lines need to retain their direction and their attributes, but the attributes of the point features do not matter.

7

The ST_Split PostGIS function is probably what you want.

PostGIS 2.2+ now supports Multi* geometries in ST_Split.

For older versions of PostGIS, read on:


To get a single line split by multiple points, you could use something like this multipoint wrapper plpgsql function. I've simplified it just to the "split (multi)lines with (multi)points" case below:

DROP FUNCTION IF EXISTS split_line_multipoint(input_geom geometry, blade geometry);
CREATE FUNCTION split_line_multipoint(input_geom geometry, blade geometry)
  RETURNS geometry AS
$BODY$
    -- this function is a wrapper around the function ST_Split 
    -- to allow splitting multilines with multipoints
    --
    DECLARE
        result geometry;
        simple_blade geometry;
        blade_geometry_type text := GeometryType(blade);
        geom_geometry_type text := GeometryType(input_geom);
    BEGIN
        IF blade_geometry_type NOT ILIKE 'MULTI%' THEN
            RETURN ST_Split(input_geom, blade);
        ELSIF blade_geometry_type NOT ILIKE '%POINT' THEN
            RAISE NOTICE 'Need a Point/MultiPoint blade';
            RETURN NULL;
        END IF;

        IF geom_geometry_type NOT ILIKE '%LINESTRING' THEN
            RAISE NOTICE 'Need a LineString/MultiLineString input_geom';
            RETURN NULL;
        END IF;

        result := input_geom;           
        -- Loop on all the points in the blade
        FOR simple_blade IN SELECT (ST_Dump(ST_CollectionExtract(blade, 1))).geom
        LOOP
            -- keep splitting the previous result
            result := ST_CollectionExtract(ST_Split(result, simple_blade), 2);
        END LOOP;
        RETURN result;
    END;
$BODY$
LANGUAGE plpgsql IMMUTABLE;

-- testing
SELECT ST_AsText(split_line_multipoint(geom, blade))
    FROM (
        SELECT ST_GeomFromText('Multilinestring((-3 0, 3 0),(-1 0, 1 0))') AS geom,
        ST_GeomFromText('MULTIPOINT((-0.5 0),(0.5 0))') AS blade
        --ST_GeomFromText('POINT(-0.5 0)') AS blade
    ) AS T;

Then to create a multipoint geometry to cut by, use ST_Collect and either create it manually from inputs:

SELECT ST_AsText(ST_Collect(
  ST_GeomFromText('POINT(1 2)'),
  ST_GeomFromText('POINT(-2 3)')
));

st_astext
----------
MULTIPOINT(1 2,-2 3)

Or aggregate it from a subquery:

SELECT stusps,
  ST_Multi(ST_Collect(f.the_geom)) as singlegeom
FROM (SELECT stusps, (ST_Dump(the_geom)).geom As the_geom
      FROM somestatetable ) As f
GROUP BY stusps
  • I tried ST_Split to begin with, and was surprised when I found that it did not accept multipoint geometry. Your function seems to fill that gap, but unfortunately it is returning NULL for the example multipoint case. (It works fine on the (single) point.) However, I changed IF blade_geometry_type NOT ILIKE '%LINESTRING' THEN to IF blade_geometry_type ILIKE '%LINESTRING' THEN in your function and got the expected and correct `GEOMETRYCOLLECTION' result. I'm still reasonably new to PostGIS however, so is that modification sensible? – Richard Law Aug 31 '14 at 21:03
  • Sorry, should have been IF geom_geometry_type NOT ILIKE '%LINESTRING' THEN - I've edited it. – rcoup Sep 1 '14 at 1:17
  • 1
    Ah, I see. Thanks, this is a great solution. You should suggest this as a contribution to ST_Split so that it can handle multiline and multipoint, if this is not already in the PostGIS pipeline. – Richard Law Sep 1 '14 at 1:25
  • 3
    ST_Split supports multi* blades in postgis 2.2 and above postgis.net/docs/ST_Split.html – raphael Mar 29 '16 at 18:33
3

Upgrade to PostGIS 2.2, where ST_Split was expanded to support splitting by a multiline, a multipoint or (multi)polygon boundary.

postgis=# SELECT postgis_version(),
                  ST_AsText(ST_Split('LINESTRING(0 0, 2 0)', 'MULTIPOINT(0 0, 1 0)'));
-[ RECORD 1 ]---+------------------------------------------------------------
postgis_version | 2.2 USE_GEOS=1 USE_PROJ=1 USE_STATS=1
st_astext       | GEOMETRYCOLLECTION(LINESTRING(1 0,2 0),LINESTRING(0 0,1 0))
2

I haven't the whole answer for you, but ST_Line_Locate_Point takes a line and a point as arguments, and returns a number between 0 and 1 representing the distance along the line to the position that is closest to the point.

ST_Line_Substring takes a line and two numbers, each between 0 and 1, as arguments. The numbers represent positions on the line as fractional distances. The function returns the line segment that runs between those two positions.

By working with these two functions, you should be able to achieve what you want to do.

  • Thanks for this. I've actually solved this problem using your technique as well as that from @rcoup. I've given him the accepted answer due to the function that should make it easy for others. If others want to go down this path, I created a temporary table of the lines that have points on them, with a row for each line and one stop that is on it. I added columns for output of ST_Line_Locate_Point(line.geom, pt.geom) AS L and a window function: rank() OVER PARTITION BY line.id ORDER BY LR). Then LEFT OUTER JOIN the temporary table, a, to itself, b, where a.id=b.id and a.LR=b.LR+1 (continued) – Richard Law Sep 1 '14 at 1:33
  • (continued) The outer join allows for a CASE WHEN the join fields are null, in which case ST_Line_Substring from the point to the end of the line, else ST_Line_Substring from the linear reference of the first point, to the linear reference of the second point (with higher rank). Getting the [start]L-A segment is then performed with a second SELECT, just picking out those with a rank of 1 and calculating the ST_Line_Substring from the line's ST_StartPoint to the linear reference of the intersecting point. Pop these in the table, remembering to keep the line.id, and voilà. Cheers. – Richard Law Sep 1 '14 at 1:38
  • Can you post this answer as an answer in code please? I'd like to look at that option as well as I'm a bit of a newbie to SQL. – Phil Donovan Sep 10 '14 at 3:42
  • 1
    @PhilDonovan: done. – Richard Law Dec 12 '14 at 5:57
2

I've been asked for this twice now, so sorry for the delay. This is unlikely to be considered a terse solution; I wrote it when a little bit further down the learning curve than I currently am. Any tips welcome, even stylistic ones.

--Inputs:
--walkingNetwork = Line features representing edges pedestrians can walk on
--stops = Bus stops
--NOTE: stops.geom is already constrained to be coincident with line features
--from walkingNetwork. They may be on a vertex or between two vertices.

--This series of queries returns a version of walkingNetwork, with edges split
--into separate features where they intersect stops.

CREATE TABLE tmp_lineswithstops AS (
    WITH subq AS (
        SELECT
        ST_Line_Locate_Point(
            roads.geom,
            ST_ClosestPoint(roads.geom, stops.geom)
        ) AS LR,
        rank() OVER (
            PARTITION BY roads.gid
            ORDER BY ST_Line_Locate_Point(
                roads.geom,
                ST_ClosestPoint(roads.geom, stops.geom)
            )
        ) AS LRRank,
        ST_ClosestPoint(roads.geom, stops.geom),
        roads.*
        FROM walkingNetwork AS roads
        LEFT OUTER JOIN stops
        ON ST_Distance(roads.geom, stops.geom) < 0.01
        WHERE ST_Equals(ST_StartPoint(roads.geom), stops.geom) IS false
        AND ST_Equals(ST_EndPoint(roads.geom), stops.geom) IS false
        ORDER BY gid, LRRank
    )
    SELECT * FROM subq
);

-- Calculate the interior edges with a join
--If the match is null, calculate the line to the end
CREATE TABLE tmp_testsplit AS (
    SELECT
    l1.gid,
    l1.geom,
    l1.lr AS LR1,
    l1.st_closestpoint AS LR1geom,
    l1.lrrank AS lr1rank,
    l2.lr AS LR2,
    l2.st_closestpoint AS LR2geom,
    l2.lrrank AS lr2rank,
    CASE WHEN l2.lrrank IS NULL -- When the point is the last along the line
        THEN ST_Line_Substring(l1.geom, l1.lr, 1) --get the substring line to the end
        ELSE ST_Line_Substring(l1.geom, l1.lr, l2.lr) --get the substring between the two points
    END AS sublinegeom
    FROM tmp_lineswithstops AS l1
    LEFT OUTER JOIN tmp_lineswithstops AS l2
    ON l1.gid = l2.gid
    AND l2.lrrank = (l1.lrrank + 1)
);

--Calculate the start to first stop edge
INSERT INTO tmp_testsplit (gid, geom, lr1, lr1geom, lr1rank, lr2, lr2geom, lr2rank, sublinegeom)
SELECT gid, geom,
0 as lr1,
ST_StartPoint(geom) as lr1geom,
0 as lr1rank,
lr AS lr2,
st_closestpoint AS lr2geom,
lrrank AS lr2rank,
ST_Line_Substring(l1.geom, 0, lr) AS sublinegeom --Start to point
FROM tmp_lineswithstops AS l1
WHERE l1.lrrank = 1;

--Now match back to the original road features, both modified and unmodified
CREATE TABLE walkingNetwork_split AS (
    SELECT
    roadssplit.sublinegeom,
    roadssplit.gid AS sgid, --split-gid
    roads.*
    FROM tmp_testsplit AS roadssplit
    JOIN walkingNetwork AS r
    ON r.gid = roadssplit.gid
    RIGHT OUTER JOIN walkingNetwork AS roads --Original edges with null if unchanged, original edges with split geom otherwise
    ON roads.gid = roadssplit.gid
);

--Now update the necessary columns, and drop the temporary columns
--You'll probably need to work on your own length and cost functions
--Here I assume it's valid to just multiply the old cost by the fraction of
--the length the now-split line represents of the non-split line
UPDATE walkingNetwork_split
SET geom = sublinegeom,
lengthz = lengthz*(ST_Length(sublinegeom)/ST_Length(geom)),
walk_seconds_ft = walk_seconds_ft*(ST_Length(sublinegeom)/ST_Length(geom)),
walk_seconds_tf = walk_seconds_tf*(ST_Length(sublinegeom)/ST_Length(geom))
WHERE sublinegeom IS NOT NULL
AND ST_Length(sublinegeom) > 0;
ALTER TABLE walkingNetwork_split
DROP COLUMN sublinegeom,
DROP COLUMN sgid;

--Drop intermediate tables
--You probably could use actual temporary tables;
--I prefer to have a sanity check at each stage
DROP TABLE IF EXISTS tmp_testsplit;
DROP TABLE IF EXISTS tmp_lineswithstops;

--Assign the edges a new unique id, so we can use this as source/target columns in pgRouting
ALTER TABLE walkingNetwork_split
DROP COLUMN IF EXISTS fid;
ALTER TABLE walkingNetwork_split
ADD COLUMN fid INTEGER;
CREATE SEQUENCE roads_seq;
UPDATE walkingNetwork_split
SET fid = nextval('roads_seq');
ALTER TABLE walkingNetwork_split
ADD PRIMARY KEY ("fid");
0

I want to expand on the answers above from a beginner's perspective. In this scenario, you have a series of points and you watch to use them as a "blade" to cut a lines into segments. This whole example assumes that you first snapped your points to the line and that the points have the unique ID attribute from their snapped line. I use 'column_id" to represent the line's unique ID.

First, you want to group your points into multipoints when more then one blade falls on a line. Otherwise, the split_line_multipoint function acts like the ST_Split function, which is not the result you want.

CREATE TABLE multple_terminal_lines AS
SELECT ST_Multi(ST_Union(the_geom)) as the_geom, a.matched_alid
FROM    point_table a
        INNER JOIN
        (
            SELECT  column_id
            FROM    point_table
            GROUP   BY column_id
            HAVING  COUNT(*) > 1
        ) b ON a.column_id = b.column_id
GROUP BY a.column_id;

Then, you want to split your network based on these multipoints.

CREATE TABLE split_multi AS
SELECT (ST_Dump(split_line_multipoint(ST_Snap(a.the_geometry, b.the_geom, 0.00001),b.the_geom))).geom as the_geom
FROM line_table a
JOIN multple_terminal_lines b 
ON a.column_id = b.column_id;


Repeat Steps 1 and 2 with your lines that have only one intersecting point. To do this, you should update the code from step 1 to 'HAVING COUNT(*) = 1'. Rename tables accordingly.


Next, make a duplicate line table and delete the entries with points on them.

CREATE TABLE line_dup AS
SELECT * FROM line_table;
-- Delete shared entries
DELETE FROM line_dup
WHERE column_id in (SELECT DISTINCT column_id FROM split_single) OR column_id in (SELECT DISTINCT column_id FROM split_multi) ;


Lastly, join your three tables together using UNION ALL:

CREATE TABLE line_filtered AS 
SELECT the_geom
FROM split_single
UNION ALL 
SELECT the_geom
FROM split_multi
UNION ALL 
SELECT the_geom
FROM line_dup;

BAM!

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.