Ticket #24983: parallel-way-24983.patch

File parallel-way-24983.patch, 41.6 KB (added by wangi, 13 days ago)
  • src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java

    diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java src/org/openstreetmap/josm/actions/mapmode/ParallelWayAction.java
    index 3715aca227..8b315edbfa 100644
    public class ParallelWayAction extends MapMode implements ModifierExListener {  
    677677
    678678                // Preview of the parallel way(s)
    679679                if (pWays != null) {
    680                     List<EastNorth> pts = pWays.getOffsetPoints();
    681                     if (pts.size() > 1) {
    682                         g.setStroke(PREVIEW_STROKE.get());
    683                         line = new MapViewPath(mv);
    684                         line.moveTo(pts.get(0));
    685                         for (int i = 1; i < pts.size(); i++) {
    686                             line.lineTo(pts.get(i));
     680                    g.setStroke(PREVIEW_STROKE.get());
     681                    for (List<EastNorth> pts : pWays.getOffsetPaths()) {
     682                        if (pts.size() > 1) {
     683                            line = new MapViewPath(mv);
     684                            line.moveTo(pts.get(0));
     685                            for (int i = 1; i < pts.size(); i++) {
     686                                line.lineTo(pts.get(i));
     687                            }
     688                            g.draw(line.computeClippedLine(g.getStroke()));
    687689                        }
    688                         if (pWays.isResultClosed()) {
    689                             line.lineTo(pts.get(0));
    690                         }
    691                         g.draw(line.computeClippedLine(g.getStroke()));
    692690                    }
    693691                }
    694692            }
  • src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java

    diff --git src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java src/org/openstreetmap/josm/actions/mapmode/ParallelWays.java
    index 8cee3b00d4..7e585810a7 100644
    import java.util.ArrayList;  
    55import java.util.Arrays;
    66import java.util.Collection;
    77import java.util.Collections;
     8import java.util.HashMap;
    89import java.util.HashSet;
    910import java.util.List;
     11import java.util.Map;
    1012import java.util.Set;
    1113import java.util.function.IntConsumer;
    1214import java.util.stream.IntStream;
    … … import org.openstreetmap.josm.data.osm.Way;  
    3638 * (approximated by chords) for sharp turns or when the mitre would overshoot too far.</li>
    3739 * <li>The resulting raw polyline is split at its self-intersections. Every piece is kept only if it lies at
    3840 * (at least) the offset distance from the source path. Pieces which are closer belong to inverted loops
    39  * ("swallowtails") and are dropped. The remaining pieces are chained; the longest chain is the result.</li>
     41 * ("swallowtails") and are dropped. The remaining pieces are chained.</li>
    4042 * </ol>
     43 * Where the path crosses itself, or comes back within twice the offset of itself, the offset can consist of
     44 * several separate chains (e.g. around a loop which passes under its own approach). All of them are kept, and each
     45 * becomes its own way(s). The exception are chains produced only by retraced segments (a path which goes out and
     46 * back along the same nodes): seen from such a segment, both sides are "its" side, so they would draw a loop
     47 * around the excursion on the far side of the path.
    4148 * All calculations are done in projected coordinates.
    4249 * <p>
    4350 * Contrary to earlier versions the nodes and ways are only created (and added to the data set) by
    4451 * {@link #commit()}, since the number of nodes of the result depends on the offset. Use
    45  * {@link #getOffsetPoints()} to draw a preview while the offset is changed.
     52 * {@link #getOffsetPaths()} to draw a preview while the offset is changed.
    4653 */
    4754public class ParallelWays {
    4855
    … … public class ParallelWays {  
    5259    private static final int NO_NODE = -1;
    5360    /** marker for raw segments of the end caps, which are only used to trim the result and are never part of it */
    5461    private static final int CAP = -2;
     62    /** maximal distance between the samples of the validity of a piece, as a fraction of the offset */
     63    private static final double SAMPLE_SPACING = 0.125;
    5564
    5665    private final List<Way> sourceWays;
    5766    private final boolean copyTags;
    … … public class ParallelWays {  
    7887    private final boolean[] wayForward;
    7988    /** source way index for each source segment */
    8089    private final int[] segWay;
     90    /** whether the path also runs along each source segment in the opposite direction (out and back) */
     91    private final boolean[] retraced;
    8192
    8293    // Spatial index of the source segments (rebuilt for every offset, since the cell size depends on it)
    8394    private double gridCell;
    … … public class ParallelWays {  
    89100    private int[] gridStamp;
    90101    private int gridQuery;
    91102
    92     // Result of the last changeOffset call
    93     private List<EastNorth> resultPts = Collections.emptyList();
    94     private int[] resultPieceSeg = new int[0];
    95     private int[] resultPointNode = new int[0];
    96     private boolean resultClosed;
     103    // Result of the last changeOffset call, longest path first
     104    private List<OffsetPath> results = Collections.emptyList();
    97105
    98106    private List<Way> ways = Collections.emptyList();
    99107
     108    /** One connected path of the result */
     109    private static final class OffsetPath {
     110        /** the points; for a ring the first point is not repeated at the end */
     111        final List<EastNorth> pts;
     112        /** for each piece (from point i to point i + 1, wrapping around for a ring): the source segment */
     113        final int[] pieceSeg;
     114        /** for each point: the source node it was derived from, or {@link #NO_NODE} */
     115        final int[] pointNode;
     116        /** for each point: an identifier shared by the points of different paths which are the same point */
     117        final int[] pointId;
     118        final boolean closed;
     119
     120        OffsetPath(List<EastNorth> pts, int[] pieceSeg, int[] pointNode, int[] pointId, boolean closed) {
     121            this.pts = pts;
     122            this.pieceSeg = pieceSeg;
     123            this.pointNode = pointNode;
     124            this.pointId = pointId;
     125            this.closed = closed;
     126        }
     127    }
     128
    100129    /**
    101130     * Constructs a new {@code ParallelWays}.
    102131     * @param sourceWays source ways
    … … public class ParallelWays {  
    221250                segWay[i] = w;
    222251            }
    223252        }
     253        retraced = findRetracedSegments();
     254    }
     255
     256    /**
     257     * Finds the segments which the path also runs along in the opposite direction, as in a - b - c - b - d.
     258     * @return for each segment, whether it is retraced
     259     */
     260    private boolean[] findRetracedSegments() {
     261        int segCount = nodeCount - 1;
     262        Set<List<Double>> segments = new HashSet<>(segCount * 2);
     263        for (int i = 0; i < segCount; i++) {
     264            segments.add(Arrays.asList(px[i], py[i], px[i + 1], py[i + 1]));
     265        }
     266        boolean[] result = new boolean[segCount];
     267        for (int i = 0; i < segCount; i++) {
     268            result[i] = segments.contains(Arrays.asList(px[i + 1], py[i + 1], px[i], py[i]));
     269        }
     270        return result;
    224271    }
    225272
    226273    /**
    … … public class ParallelWays {  
    341388        if (d == 0 || Double.isNaN(d)) {
    342389            // no offset: the result is a copy (for a ring without the repeated closing node)
    343390            int pointCount = closed ? nodeCount - 1 : nodeCount;
    344             resultPts = new ArrayList<>(pointCount);
     391            List<EastNorth> pts = new ArrayList<>(pointCount);
    345392            for (int i = 0; i < pointCount; i++) {
    346                 resultPts.add(new EastNorth(px[i], py[i]));
     393                pts.add(new EastNorth(px[i], py[i]));
    347394            }
    348             resultPieceSeg = IntStream.range(0, nodeCount - 1).toArray();
    349             resultPointNode = IntStream.range(0, pointCount).toArray();
    350             resultClosed = closed;
     395            int[] indices = IntStream.range(0, pointCount).toArray();
     396            results = Collections.singletonList(
     397                    new OffsetPath(pts, IntStream.range(0, nodeCount - 1).toArray(), indices, indices, closed));
    351398            return;
    352399        }
    353400        buildGrid(Math.abs(d));
    … … public class ParallelWays {  
    720767            chain.append(pieces.end[p], srcSeg, points);
    721768        }
    722769        // Join chains whose end meets the start of another chain (a ring may also close onto itself)
    723         boolean merged = true;
    724         while (merged && chains.size() > 1) {
    725             merged = false;
    726             for (int i = 0; i < chains.size() && !merged; i++) {
    727                 Chain a = chains.get(i);
     770        // (each chain is extended as far as it goes before moving on: starts never reappear, so there is no need to
     771        // look at a chain again)
     772        for (int i = 0; i < chains.size(); i++) {
     773            Chain a = chains.get(i);
     774            boolean merged = true;
     775            while (merged) {
     776                merged = false;
    728777                for (int j = 0; j < chains.size(); j++) {
    729778                    Chain b = chains.get(j);
    730779                    int bFirst = b.ids.get(0);
    … … public class ParallelWays {  
    734783                        }
    735784                        a.appendChain(b);
    736785                        chains.remove(j);
     786                        if (j < i) {
     787                            i--;
     788                        }
    737789                        merged = true;
    738790                        break;
    739791                    }
    740792                }
    741793            }
    742794        }
    743         if (chains.isEmpty()) {
    744             setEmptyResult();
    745             return;
    746         }
    747         Chain best = chains.get(0);
    748         for (Chain ch : chains) {
    749             if (ch.length > best.length) {
    750                 best = ch;
     795        // A chain made only of retraced segments goes around an out-and-back excursion of the path: drop it, unless
     796        // nothing else is left (the whole path goes out and back)
     797        if (chains.stream().anyMatch(ch -> !isRetracedOnly(ch))) {
     798            chains.removeIf(this::isRetracedOnly);
     799        }
     800        chains.removeIf(ch -> ch.ids.size() < 2);
     801        chains.sort((a, b) -> Double.compare(b.length, a.length));
     802        List<OffsetPath> paths = new ArrayList<>(chains.size());
     803        for (Chain chain : chains) {
     804            if (chain.ids.size() > 2 && chain.lastId != chain.ids.get(0)
     805                    && points.distance(chain.lastId, chain.ids.get(0)) <= bridgeTolerance) {
     806                // close a ring which is open by a small gap only (also for an open path, around e.g. a pocket of sea
     807                // which is further than the offset from the coast); a piece which is merely short is not a ring
     808                chain.append(chain.ids.get(0), chain.segs.get(chain.segs.size() - 1), points);
    751809            }
     810            paths.add(toOffsetPath(chain, raw, points, r));
    752811        }
    753         if (closed && best.lastId != best.ids.get(0) && points.distance(best.lastId, best.ids.get(0)) <= bridgeTolerance) {
    754             // close a ring which is open by a small gap only
    755             best.append(best.ids.get(0), best.segs.get(best.segs.size() - 1), points);
    756         }
     812        results = paths;
     813    }
     814
     815    /**
     816     * Determines if a chain is made only of retraced segments, see {@link #findRetracedSegments()}.
     817     * @param chain the chain
     818     * @return {@code true} if every piece of the chain is the offset of a retraced segment
     819     */
     820    private boolean isRetracedOnly(Chain chain) {
     821        return chain.segs.stream().allMatch(seg -> seg >= 0 && retraced[seg]);
     822    }
    757823
    758         // Convert to the result
    759         boolean isRing = closed && best.ids.size() > 2 && best.ids.get(0) == best.lastId;
    760         List<Integer> ids = new ArrayList<>(best.ids);
    761         List<Integer> segs = new ArrayList<>(best.segs);
    762         replaceIntactArcsByMitres(raw, points, ids, segs);
     824    /**
     825     * Converts a chain into a path of the result.
     826     * @param chain the chain
     827     * @param raw the raw polyline
     828     * @param points the point table
     829     * @param r the offset
     830     * @return the path
     831     */
     832    private OffsetPath toOffsetPath(Chain chain, RawPolyline raw, Points points, double r) {
     833        boolean isRing = chain.ids.size() > 2 && chain.ids.get(0) == chain.lastId;
     834        List<Integer> ids = new ArrayList<>(chain.ids);
     835        List<Integer> segs = new ArrayList<>(chain.segs);
     836        replaceIntactArcsByMitres(raw, points, ids, segs, r);
    763837        if (isRing) {
    764838            ids.remove(ids.size() - 1);
    765839            // rotate so that the ring starts at a way boundary
    … … public class ParallelWays {  
    774848            Collections.rotate(ids, -rot);
    775849            Collections.rotate(segs, -rot);
    776850        }
    777         resultPts = new ArrayList<>(ids.size());
    778         resultPointNode = new int[ids.size()];
     851        List<EastNorth> pts = new ArrayList<>(ids.size());
     852        int[] pointNode = new int[ids.size()];
     853        int[] pointId = new int[ids.size()];
    779854        for (int i = 0; i < ids.size(); i++) {
    780855            int id = ids.get(i);
    781856            double[] pt = points.get(id);
    782             resultPts.add(new EastNorth(pt[0], pt[1]));
    783             resultPointNode[i] = points.node.get(id);
     857            pts.add(new EastNorth(pt[0], pt[1]));
     858            pointNode[i] = points.node.get(id);
     859            pointId[i] = id;
    784860        }
    785         resultPieceSeg = segs.stream().mapToInt(Integer::intValue).toArray();
    786         resultClosed = isRing;
     861        return new OffsetPath(pts, segs.stream().mapToInt(Integer::intValue).toArray(), pointNode, pointId, isRing);
    787862    }
    788863
    789864    private void setEmptyResult() {
    790         resultPts = Collections.emptyList();
    791         resultPieceSeg = new int[0];
    792         resultPointNode = new int[0];
    793         resultClosed = false;
     865        results = Collections.emptyList();
    794866    }
    795867
    796868    /**
    797869     * Determines the validity of the piece of raw segment {@code i} between two points, and adds the resulting
    798      * piece(s). The validity is sampled at both ends and in the middle; if it changes, the transition point is
    799      * located and the piece is split there.
     870     * piece(s). The validity is sampled at both ends and in between, at most {@link #SAMPLE_SPACING} times the offset
     871     * apart; wherever it changes, the transition point is located and the piece is split there.
    800872     */
    801873    private void classify(RawPolyline raw, double r, Points points, Pieces pieces, int i, int sId, int eId) {
    802874        int endPoint = (i + 1) % raw.size;
    … … public class ParallelWays {  
    808880        double[] s = points.get(sId);
    809881        double[] e = points.get(eId);
    810882        double tolerance = r * 1e-7 + 1e-7;
    811         boolean vs = isValid(s[0], s[1], r, tolerance, arc);
    812         boolean ve = isValid(e[0], e[1], r, tolerance, arc);
    813         boolean vm = isValid(s[0] + (e[0] - s[0]) / 2, s[1] + (e[1] - s[1]) / 2, r, tolerance, arc);
    814         if (vs == vm && vm == ve) {
    815             pieces.add(sId, eId, i, vm);
    816             return;
    817         }
     883        double len = Math.hypot(e[0] - s[0], e[1] - s[1]);
     884        // A piece may pass within r of another part of the path somewhere in its middle (e.g. where the path crosses
     885        // itself), so sampling its ends and middle is not enough for a long piece. With samples r / 8 apart, only a
     886        // stretch which grazes the r-circle by less than about 0.2 % of r can be missed.
     887        int intervals = Math.max(2, (int) Math.ceil(len / (r * SAMPLE_SPACING)));
     888        // For many samples, gather the source segments near the piece once instead of searching for each sample
     889        int[] nearby = intervals == 2 ? null : nearbySegments(Math.min(s[0], e[0]), Math.min(s[1], e[1]),
     890                Math.max(s[0], e[0]), Math.max(s[1], e[1]), r);
    818891        // Transitions very close to an end point are artefacts of the chord approximation (a point where two
    819         // chords cross lies inside both circles): the validity of the middle is then used for the whole piece.
     892        // chords cross lies inside both circles): the validity of the interior is then used up to the end point.
    820893        double snapTolerance = 2 * r * (1 - Math.cos(arcStep / 2)) + tolerance;
    821         double len = Math.hypot(e[0] - s[0], e[1] - s[1]);
    822894        int prev = sId;
    823         boolean state = vs;
    824         if (vs != vm) {
    825             double t1 = locateTransition(s, e, 0, 0.5, r, tolerance, arc);
    826             if (t1 * len <= snapTolerance) {
    827                 state = vm;
    828             } else {
    829                 int id = points.add(s[0] + (e[0] - s[0]) * t1, s[1] + (e[1] - s[1]) * t1, NO_NODE);
    830                 pieces.add(prev, id, i, state);
    831                 prev = id;
    832                 state = vm;
    833             }
    834         }
    835         if (vm != ve) {
    836             double t2 = locateTransition(s, e, 0.5, 1, r, tolerance, arc);
    837             if ((1 - t2) * len > snapTolerance) {
    838                 int id = points.add(s[0] + (e[0] - s[0]) * t2, s[1] + (e[1] - s[1]) * t2, NO_NODE);
    839                 pieces.add(prev, id, i, state);
    840                 prev = id;
    841                 state = ve;
     895        boolean state = isValid(s[0], s[1], r, tolerance, arc, nearby);
     896        boolean sampled = state;
     897        double tPrev = 0;
     898        for (int k = 1; k <= intervals; k++) {
     899            double t = (double) k / intervals;
     900            boolean v = isValid(s[0] + (e[0] - s[0]) * t, s[1] + (e[1] - s[1]) * t, r, tolerance, arc, nearby);
     901            if (v != sampled) {
     902                double tt = locateTransition(s, e, tPrev, t, r, tolerance, arc, nearby);
     903                if (tt * len <= snapTolerance) {
     904                    state = v;
     905                } else if ((1 - tt) * len > snapTolerance) {
     906                    int id = points.add(s[0] + (e[0] - s[0]) * tt, s[1] + (e[1] - s[1]) * tt, NO_NODE);
     907                    pieces.add(prev, id, i, state);
     908                    prev = id;
     909                    state = v;
     910                }
     911                sampled = v;
    842912            }
     913            tPrev = t;
    843914        }
    844915        pieces.add(prev, eId, i, state);
    845916    }
    … … public class ParallelWays {  
    848919     * Locates (by bisection) the parameter between {@code a} and {@code b} where the validity changes.
    849920     * @return the parameter of the transition
    850921     */
    851     private double locateTransition(double[] s, double[] e, double a, double b, double r, double tolerance, int arc) {
    852         boolean va = isValid(s[0] + (e[0] - s[0]) * a, s[1] + (e[1] - s[1]) * a, r, tolerance, arc);
     922    private double locateTransition(double[] s, double[] e, double a, double b, double r, double tolerance, int arc,
     923            int[] nearby) {
     924        boolean va = isValid(s[0] + (e[0] - s[0]) * a, s[1] + (e[1] - s[1]) * a, r, tolerance, arc, nearby);
    853925        for (int it = 0; it < 40 && b - a > 1e-12; it++) {
    854926            double mid = (a + b) / 2;
    855             boolean vmid = isValid(s[0] + (e[0] - s[0]) * mid, s[1] + (e[1] - s[1]) * mid, r, tolerance, arc);
     927            boolean vmid = isValid(s[0] + (e[0] - s[0]) * mid, s[1] + (e[1] - s[1]) * mid, r, tolerance, arc, nearby);
    856928            if (vmid == va) {
    857929                a = mid;
    858930            } else {
    … … public class ParallelWays {  
    870942     * @param tolerance allowed deficit
    871943     * @param arc if the point lies on an arc chord: the center node of the arc; the point is then projected onto
    872944     * the arc before testing, else {@link #NO_NODE}
     945     * @param nearby the source segments which can come within r of the point, or {@code null} to search them all
    873946     * @return {@code true} if the point is valid
    874947     */
    875     private boolean isValid(double x, double y, double r, double tolerance, int arc) {
     948    private boolean isValid(double x, double y, double r, double tolerance, int arc, int[] nearby) {
    876949        if (arc != NO_NODE) {
    877950            double vx = x - px[arc];
    878951            double vy = y - py[arc];
    … … public class ParallelWays {  
    882955                y = py[arc] + vy / vl * r;
    883956            }
    884957        }
    885         return distanceToSource(x, y, r - tolerance) >= r - tolerance;
     958        double dist = nearby == null ? distanceToSource(x, y, r - tolerance) : distanceToSegments(nearby, x, y, r - tolerance);
     959        return dist >= r - tolerance;
    886960    }
    887961
    888962    /**
    889      * Replaces arcs which are completely part of the result by a mitre (where the corner is gentle enough).
     963     * Replaces arcs which are completely part of the result by a mitre (where the corner is gentle enough, and the
     964     * mitre keeps the distance to the rest of the path).
    890965     */
    891     private static void replaceIntactArcsByMitres(RawPolyline raw, Points points, List<Integer> ids, List<Integer> segs) {
     966    private void replaceIntactArcsByMitres(RawPolyline raw, Points points, List<Integer> ids, List<Integer> segs, double r) {
    892967        int[] arcOfRawPoint = new int[raw.size];
    893968        Arrays.fill(arcOfRawPoint, NO_NODE);
    894969        for (int k = 0; k < raw.arcStart.length; k++) {
    … … public class ParallelWays {  
    910985            for (int j = 1; j <= chords && intact; j++) {
    911986                intact = ids.get(i + j) == id + j;
    912987            }
    913             if (!intact) {
     988            if (!intact || !isValidMitre(raw, k, r)) {
    914989                continue;
    915990            }
    916991            int mitreId = points.add(raw.mitreX[k], raw.mitreY[k], k);
    … … public class ParallelWays {  
    923998        }
    924999    }
    9251000
     1001    /**
     1002     * Checks whether the mitre at node {@code k} keeps the offset distance to the source path. It sticks out beyond
     1003     * the arc it replaces, so it may come too close to another part of the path even though the arc does not.
     1004     * @param raw the raw polyline
     1005     * @param k the source node of the arc
     1006     * @param r the offset
     1007     * @return {@code true} if both legs of the mitre (from the ends of the arc to the mitre point) are valid
     1008     */
     1009    private boolean isValidMitre(RawPolyline raw, int k, double r) {
     1010        double tolerance = r * 1e-7 + 1e-7;
     1011        for (int end : new int[] {raw.arcStart[k], raw.arcStart[k] + raw.arcChords[k]}) {
     1012            double dx = raw.mitreX[k] - raw.x[end];
     1013            double dy = raw.mitreY[k] - raw.y[end];
     1014            int intervals = Math.max(1, (int) Math.ceil(Math.hypot(dx, dy) / (r * SAMPLE_SPACING)));
     1015            for (int j = 1; j <= intervals; j++) {
     1016                double t = (double) j / intervals;
     1017                if (!isValid(raw.x[end] + dx * t, raw.y[end] + dy * t, r, tolerance, NO_NODE, null)) {
     1018                    return false;
     1019                }
     1020            }
     1021        }
     1022        return true;
     1023    }
     1024
    9261025    /**
    9271026     * Finds the chain ending at (or within the tolerance of) the given point, preferring an exact match.
    9281027     * @return the chain, or {@code null}
    … … public class ParallelWays {  
    11041203                    if (x < segMinX[i] - best || x > segMaxX[i] + best || y < segMinY[i] - best || y > segMaxY[i] + best) {
    11051204                        continue;
    11061205                    }
    1107                     double rx = x - px[i];
    1108                     double ry = y - py[i];
    1109                     double t = rx * dirX[i] + ry * dirY[i];
    1110                     double dSq;
    1111                     if (t <= 0) {
    1112                         dSq = rx * rx + ry * ry;
    1113                     } else if (t >= segLen[i]) {
    1114                         double ex = x - px[i + 1];
    1115                         double ey = y - py[i + 1];
    1116                         dSq = ex * ex + ey * ey;
    1117                     } else {
    1118                         double c = rx * dirY[i] - ry * dirX[i];
    1119                         dSq = c * c;
    1120                     }
     1206                    double dSq = segmentDistanceSq(i, x, y);
    11211207                    if (dSq < bestSq) {
    11221208                        bestSq = dSq;
    11231209                        best = Math.sqrt(dSq);
    … … public class ParallelWays {  
    11311217        return best;
    11321218    }
    11331219
     1220    /**
     1221     * Distance from a point to some of the source segments.
     1222     * @param segments indices of the segments
     1223     * @param x point
     1224     * @param y point
     1225     * @param cutoff the search can stop as soon as a distance below this value is found
     1226     * @return the distance (or any value below cutoff if such a distance exists)
     1227     */
     1228    private double distanceToSegments(int[] segments, double x, double y, double cutoff) {
     1229        double bestSq = Double.POSITIVE_INFINITY;
     1230        double cutoffSq = cutoff * cutoff;
     1231        for (int i : segments) {
     1232            bestSq = Math.min(bestSq, segmentDistanceSq(i, x, y));
     1233            if (bestSq < cutoffSq) {
     1234                break;
     1235            }
     1236        }
     1237        return Math.sqrt(bestSq);
     1238    }
     1239
     1240    /**
     1241     * Squared distance from a point to a source segment.
     1242     * @param i segment index
     1243     * @param x point
     1244     * @param y point
     1245     * @return the squared distance
     1246     */
     1247    private double segmentDistanceSq(int i, double x, double y) {
     1248        double rx = x - px[i];
     1249        double ry = y - py[i];
     1250        double t = rx * dirX[i] + ry * dirY[i];
     1251        if (t <= 0) {
     1252            return rx * rx + ry * ry;
     1253        } else if (t >= segLen[i]) {
     1254            double ex = x - px[i + 1];
     1255            double ey = y - py[i + 1];
     1256            return ex * ex + ey * ey;
     1257        }
     1258        double c = rx * dirY[i] - ry * dirX[i];
     1259        return c * c;
     1260    }
     1261
     1262    /**
     1263     * Finds the source segments which may come within the given distance of an area.
     1264     * @param minX area
     1265     * @param minY area
     1266     * @param maxX area
     1267     * @param maxY area
     1268     * @param r the distance
     1269     * @return the indices of the segments whose bounding box comes within r of the area
     1270     */
     1271    private int[] nearbySegments(double minX, double minY, double maxX, double maxY, double r) {
     1272        gridQuery++;
     1273        List<Integer> found = new ArrayList<>();
     1274        forEachCell(minX - r, minY - r, maxX + r, maxY + r, c -> {
     1275            for (int i : gridCells[c]) {
     1276                if (gridStamp[i] != gridQuery) {
     1277                    gridStamp[i] = gridQuery;
     1278                    if (segMaxX[i] >= minX - r && segMinX[i] <= maxX + r && segMaxY[i] >= minY - r && segMinY[i] <= maxY + r) {
     1279                        found.add(i);
     1280                    }
     1281                }
     1282            }
     1283        });
     1284        return found.stream().mapToInt(Integer::intValue).toArray();
     1285    }
     1286
    11341287    // ---------------------------------------------------------------------------------------------------------
    11351288    // Result access and commit
    11361289
    11371290    /**
    1138      * Returns the points of the offset path computed by the last call to {@link #changeOffset(double)}.
     1291     * Returns the points of the longest offset path computed by the last call to {@link #changeOffset(double)}.
    11391292     * For a closed result the first point is not repeated at the end, see {@link #isResultClosed()}.
     1293     * Use {@link #getOffsetPaths()} for all of them.
    11401294     * @return the offset points (projected coordinates), empty if nothing has been computed or nothing remains
    11411295     * @since 19624
    11421296     */
    11431297    public List<EastNorth> getOffsetPoints() {
    1144         return Collections.unmodifiableList(resultPts);
     1298        return results.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(results.get(0).pts);
    11451299    }
    11461300
    11471301    /**
    1148      * Determines if the result of the last {@link #changeOffset(double)} call is a closed ring.
     1302     * Determines if the longest offset path of the last {@link #changeOffset(double)} call is a closed ring.
    11491303     * @return {@code true} if the offset path is a closed ring
    11501304     * @since 19624
    11511305     */
    11521306    public boolean isResultClosed() {
    1153         return resultClosed;
     1307        return !results.isEmpty() && results.get(0).closed;
     1308    }
     1309
     1310    /**
     1311     * Returns all offset paths computed by the last call to {@link #changeOffset(double)}, longest first. There is
     1312     * more than one where the source path crosses itself or comes back within twice the offset of itself. A closed
     1313     * path (ring) repeats its first point at the end.
     1314     * @return the offset paths (projected coordinates), empty if nothing has been computed or nothing remains
     1315     * @since xxx
     1316     */
     1317    public List<List<EastNorth>> getOffsetPaths() {
     1318        List<List<EastNorth>> paths = new ArrayList<>(results.size());
     1319        for (OffsetPath path : results) {
     1320            List<EastNorth> pts = new ArrayList<>(path.pts);
     1321            if (path.closed && !pts.isEmpty()) {
     1322                pts.add(pts.get(0));
     1323            }
     1324            paths.add(Collections.unmodifiableList(pts));
     1325        }
     1326        return Collections.unmodifiableList(paths);
    11541327    }
    11551328
    11561329    /**
    … … public class ParallelWays {  
    11911364    }
    11921365
    11931366    /**
    1194      * Builds the (not yet added) ways from the last computed offset path.
    1195      * @return the ways, in the order of the source ways; ways swallowed by the offset are omitted
     1367     * Builds the (not yet added) ways from the last computed offset paths.
     1368     * @return the ways, per offset path in the order of the source ways; ways swallowed by the offset are omitted
    11961369     */
    11971370    private List<Way> buildWays() {
    1198         int pointCount = resultPts.size();
    11991371        List<Way> result = new ArrayList<>(sourceWays.size());
    1200         if (pointCount < 2) {
    1201             return result;
    1202         }
    1203         int pieceCount = resultPieceSeg.length;
    1204         Node[] nodes = new Node[pointCount];
    1205         for (int w = 0; w < sourceWays.size(); w++) {
    1206             int first = -1;
    1207             int last = -1;
    1208             for (int p = 0; p < pieceCount; p++) {
    1209                 if (segWay[resultPieceSeg[p]] == w) {
    1210                     if (first < 0) {
    1211                         first = p;
     1372        // paths which meet at a common point share its node
     1373        Map<Integer, Node> nodes = new HashMap<>();
     1374        for (OffsetPath path : results) {
     1375            int pointCount = path.pts.size();
     1376            if (pointCount < 2) {
     1377                continue;
     1378            }
     1379            int pieceCount = path.pieceSeg.length;
     1380            for (int w = 0; w < sourceWays.size(); w++) {
     1381                int first = -1;
     1382                int last = -1;
     1383                for (int p = 0; p < pieceCount; p++) {
     1384                    if (segWay[path.pieceSeg[p]] == w) {
     1385                        if (first < 0) {
     1386                            first = p;
     1387                        }
     1388                        last = p;
    12121389                    }
    1213                     last = p;
    12141390                }
    1215             }
    1216             if (first < 0) {
    1217                 continue; // way is swallowed by the offset
    1218             }
    1219             List<Node> wayNodes = new ArrayList<>(last - first + 2);
    1220             for (int p = first; p <= last + 1; p++) {
    1221                 int idx = p % pointCount;
    1222                 if (nodes[idx] == null) {
    1223                     nodes[idx] = makeNode(idx);
     1391                if (first < 0) {
     1392                    continue; // way is swallowed by the offset
    12241393                }
    1225                 wayNodes.add(nodes[idx]);
    1226             }
    1227             if (!wayForward[w]) {
    1228                 Collections.reverse(wayNodes);
    1229             }
    1230             Way source = sourceWays.get(w);
    1231             Way copy = new Way();
    1232             copy.setNodes(wayNodes);
    1233             if (copyTags) {
    1234                 copy.setKeys(source.getKeys());
     1394                List<Node> wayNodes = new ArrayList<>(last - first + 2);
     1395                for (int p = first; p <= last + 1; p++) {
     1396                    int idx = p % pointCount;
     1397                    wayNodes.add(nodes.computeIfAbsent(path.pointId[idx], id -> makeNode(path, idx)));
     1398                }
     1399                if (!wayForward[w]) {
     1400                    Collections.reverse(wayNodes);
     1401                }
     1402                Way source = sourceWays.get(w);
     1403                Way copy = new Way();
     1404                copy.setNodes(wayNodes);
     1405                if (copyTags) {
     1406                    copy.setKeys(source.getKeys());
     1407                }
     1408                result.add(copy);
    12351409            }
    1236             result.add(copy);
    12371410        }
    12381411        return result;
    12391412    }
    12401413
    1241     private Node makeNode(int idx) {
     1414    private Node makeNode(OffsetPath path, int idx) {
    12421415        Node n;
    1243         int src = resultPointNode[idx];
     1416        int src = path.pointNode[idx];
    12441417        if (copyTags && src != NO_NODE) {
    12451418            n = new Node(sortedNodes.get(src), true);
    12461419        } else {
    12471420            n = new Node();
    12481421        }
    1249         n.setEastNorth(resultPts.get(idx));
     1422        n.setEastNorth(path.pts.get(idx));
    12501423        return n;
    12511424    }
    12521425
  • test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java

    diff --git test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java test/unit/org/openstreetmap/josm/actions/mapmode/ParallelWaysTest.java
    index de8a663142..11e66b1a13 100644
    import static org.junit.jupiter.api.Assertions.assertSame;  
    88import static org.junit.jupiter.api.Assertions.assertTrue;
    99
    1010import java.util.Arrays;
     11import java.util.Collection;
     12import java.util.Collections;
    1113import java.util.List;
    1214import java.util.Random;
    1315
    … … class ParallelWaysTest {  
    7072        return best;
    7173    }
    7274
     75    private static double distanceToWays(Collection<Way> ways, EastNorth p) {
     76        return ways.stream().mapToDouble(w -> distanceToWay(w, p)).min().orElse(Double.POSITIVE_INFINITY);
     77    }
     78
    7379    private static double maxSegmentLength(Way w) {
    7480        double max = 0;
    7581        for (int i = 0; i < w.getNodesCount() - 1; i++) {
    … … class ParallelWaysTest {  
    115121        }
    116122    }
    117123
     124    /**
     125     * Checks that no point of the parallel comes closer to the source than the offset (less the tolerance of the
     126     * chord approximation of arcs). Unlike {@link #assertVertexDistances} this also samples the segments, which may
     127     * pass another part of the path between two vertices.
     128     * @param source the source ways
     129     * @param pts the parallel
     130     * @param r the (absolute) offset
     131     * @param spacing the maximal distance between two samples
     132     */
     133    private static void assertSegmentDistances(Collection<Way> source, List<EastNorth> pts, double r, double spacing) {
     134        double minDist = r * (1 - 2 * (1 - Math.cos(Math.toRadians(ParallelWays.DEFAULT_ARC_STEP_DEGREES / 2)))) * (1 - 1e-6);
     135        for (int i = 0; i < pts.size() - 1; i++) {
     136            EastNorth a = pts.get(i);
     137            EastNorth b = pts.get(i + 1);
     138            int n = Math.max(1, (int) Math.ceil(a.distance(b) / spacing));
     139            for (int k = 0; k <= n; k++) {
     140                EastNorth p = a.interpolate(b, (double) k / n);
     141                double dist = distanceToWays(source, p);
     142                assertTrue(dist >= minDist, "point " + p + " of segment " + i + " too close to source: " + dist + " < " + r);
     143            }
     144        }
     145    }
     146
     147    private static void assertPoints(List<EastNorth> actual, double... xy) {
     148        assertEquals(xy.length / 2, actual.size(), actual.toString());
     149        for (int i = 0; i < actual.size(); i++) {
     150            // points where the validity changes are located to within the tolerance of the validity test
     151            assertTrue(actual.get(i).equalsEpsilon(new EastNorth(xy[2 * i], xy[2 * i + 1]), 1e-4), i + ": " + actual);
     152        }
     153    }
     154
    118155    /**
    119156     * A small offset of a simple way must keep the node count
    120157     */
    … … class ParallelWaysTest {  
    234271            long millis = (System.nanoTime() - start) / 1_000_000;
    235272            List<EastNorth> pts = pw.getOffsetPoints();
    236273            assertTrue(pts.size() >= 2, "d=" + d);
    237             assertVertexDistances(coast, pts, Math.abs(d));
     274            for (List<EastNorth> path : pw.getOffsetPaths()) {
     275                assertTrue(path.size() >= 2, "d=" + d + ": degenerate path " + path);
     276                assertVertexDistances(coast, path, Math.abs(d));
     277                assertSegmentDistances(Collections.singletonList(coast), path, Math.abs(d), Math.abs(d) / 2);
     278            }
    238279            assertTrue(millis < 5000, "offset took " + millis + " ms");
    239280            System.out.println("ParallelWays: " + count + " nodes, d=" + d + " -> " + pts.size() + " points in " + millis + " ms");
    240281        }
    … … class ParallelWaysTest {  
    254295            List<EastNorth> pts = pw.getOffsetPoints();
    255296            double r = Math.abs(d);
    256297            assertVertexDistances(w, pts, r);
     298            // the offset of the excursion itself on the far side (a loop around it) is not wanted
     299            assertEquals(1, pw.getOffsetPaths().size(), "d=" + d + ": " + pw.getOffsetPaths());
    257300            assertEquals(new EastNorth(0, d), pts.get(0), "d=" + d);
    258301            assertEquals(new EastNorth(2000, d), pts.get(pts.size() - 1), "d=" + d);
    259302            // every vertex of the main path has the parallel within reach
    … … class ParallelWaysTest {  
    276319        }
    277320    }
    278321
     322    /**
     323     * A path which only goes out and back along the same nodes still has a parallel, although all of it is the
     324     * offset of retraced segments.
     325     */
     326    @Test
     327    void testRetracedOnly() {
     328        Way w = way(0, 0, 500, 0, 1000, 0, 500, 0, 0, 0);
     329        ParallelWays pw = new ParallelWays(Arrays.asList(w), false, 0);
     330        pw.changeOffset(50);
     331        assertFalse(pw.getOffsetPaths().isEmpty());
     332        assertVertexDistances(w, pw.getOffsetPoints(), 50);
     333    }
     334
     335    /**
     336     * A path which crosses itself, like a road looping round and passing under its own approach: on both sides the
     337     * parallel consists of separate paths, and all of them are kept, longest first. None of them may cross the road.
     338     * Non-regression test for #24983, where only the longest one was kept.
     339     */
     340    @Test
     341    void testSelfCrossingLoop() {
     342        // north along x = 0, a clockwise loop to the east, back west along y = 100 under the approach, then north
     343        // along x = -300
     344        Way w = way(0, -1000, 0, 300, 300, 300, 300, 100, -300, 100, -300, 1300);
     345        List<Way> source = Collections.singletonList(w);
     346        ParallelWays pw = new ParallelWays(source, false, 0);
     347
     348        pw.changeOffset(-50); // right: on the inside of the loop
     349        List<List<EastNorth>> paths = pw.getOffsetPaths();
     350        assertEquals(3, paths.size(), paths.toString());
     351        // the westward leg and the road north, starting where the leg comes clear of the approach
     352        assertPoints(paths.get(0), -50, 150, -250, 150, -250, 1300);
     353        // the approach, up to where it passes under the westward leg
     354        assertPoints(paths.get(1), 50, -1000, 50, 50);
     355        // the ring inside the loop
     356        assertPoints(paths.get(2), 50, 150, 50, 250, 250, 250, 250, 150, 50, 150);
     357        assertEquals(paths.get(0), pw.getOffsetPoints());
     358        assertFalse(pw.isResultClosed());
     359        paths.forEach(path -> assertSegmentDistances(source, path, 50, 5));
     360
     361        pw.changeOffset(50); // left: on the outside of the loop
     362        paths = pw.getOffsetPaths();
     363        assertEquals(2, paths.size(), paths.toString());
     364        // the approach, then under the loop along the westward leg, and north
     365        assertPoints(paths.get(0), -50, -1000, -50, 50, -350, 50, -350, 1300);
     366        // around the outside of the loop, from where it comes clear of the approach until it comes back to it
     367        assertPoints(paths.get(1), -50, 150, -50, 350, 350, 350, 350, 50, 50, 50);
     368        paths.forEach(path -> assertSegmentDistances(source, path, 50, 5));
     369    }
     370
     371    /**
     372     * A bay with a mouth narrower than twice the offset: the water further than the offset from the shore is an
     373     * enclosed pocket, so its parallel is a ring, although the source path is open. At the mouth the ring passes the
     374     * two ends of the path, whose round caps are never part of the result: the small gap they leave is closed.
     375     */
     376    @Test
     377    void testBayPocketRing() {
     378        // counter clockwise around the bay (water on the left) with wavy shores, leaving a mouth at angle 0
     379        int steps = 120;
     380        double mouth = 0.15;
     381        double[] xy = new double[2 * (steps + 1)];
     382        for (int i = 0; i <= steps; i++) {
     383            double a = mouth + (2 * Math.PI - 2 * mouth) * i / steps;
     384            double rad = 1000 + 150 * Math.cos(5 * a);
     385            xy[2 * i] = rad * Math.cos(a);
     386            xy[2 * i + 1] = rad * Math.sin(a);
     387        }
     388        Way w = way(xy);
     389        ParallelWays pw = new ParallelWays(Arrays.asList(w), false, 0);
     390        double r = 250;
     391        pw.changeOffset(r);
     392        List<List<EastNorth>> paths = pw.getOffsetPaths();
     393        assertFalse(paths.isEmpty());
     394        for (List<EastNorth> path : paths) {
     395            // a closed path repeats its first point; an open one must not end just short of its start
     396            double gap = path.get(0).distance(path.get(path.size() - 1));
     397            assertTrue(gap == 0 || gap > r, "path open by a gap of " + gap + ": " + path);
     398            assertSegmentDistances(Collections.singletonList(w), path, r, r / 8);
     399        }
     400        assertTrue(paths.stream().anyMatch(path -> path.size() > 2 && path.get(0).equals(path.get(path.size() - 1))), paths.toString());
     401    }
     402
     403    /**
     404     * The separate paths of a self-crossing path become separate ways; a ring becomes a closed way.
     405     */
     406    @Test
     407    void testCommitSelfCrossingLoop() {
     408        DataSet ds = new DataSet();
     409        OsmDataLayer layer = new OsmDataLayer(ds, "ParallelWaysTest", null);
     410        MainApplication.getLayerManager().addLayer(layer);
     411        try {
     412            Way w = way(0, -1000, 0, 300, 300, 300, 300, 100, -300, 100, -300, 1300);
     413            w.put("highway", "service");
     414            List<Way> result = commitWays(ds, Collections.singletonList(w), 0, -50);
     415            assertEquals(3, result.size(), result.toString());
     416            assertTrue(result.stream().allMatch(r -> "service".equals(r.get("highway"))));
     417            assertFalse(result.get(0).isClosed());
     418            assertFalse(result.get(1).isClosed());
     419            assertTrue(result.get(2).isClosed());
     420            assertEquals(5, result.get(2).getNodesCount());
     421        } finally {
     422            MainApplication.getLayerManager().removeLayer(layer);
     423        }
     424    }
     425
    279426    /**
    280427     * The closest point of the path relates the offset to the segment next to the mouse
    281428     */