View Javadoc
1   package org.djutils.draw.curve;
2   
3   import java.util.ArrayList;
4   import java.util.HashSet;
5   import java.util.LinkedHashMap;
6   import java.util.List;
7   import java.util.Map;
8   import java.util.NavigableMap;
9   import java.util.Set;
10  import java.util.TreeMap;
11  
12  import org.djutils.draw.function.ContinuousPiecewiseLinearFunction;
13  import org.djutils.draw.line.PolyLine2d;
14  import org.djutils.draw.point.Point2d;
15  import org.djutils.exceptions.Throw;
16  import org.djutils.math.AngleUtil;
17  
18  /**
19   * Flattens a Curve2d with piece-wise linear offset in to a PolyLine2d.
20   * <p>
21   * Copyright (c) 2023-2025 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
22   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
23   * </p>
24   * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
25   * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
26   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
27   */
28  public interface OffsetFlattener2d extends Flattener<Flattener2d, Curve2d, PolyLine2d, Point2d, Double>
29  {
30      /**
31       * Flatten a OffsetCurve2d curve into a PolyLine2d while applying lateral offsets.
32       * @param curve the curve
33       * @param of the lateral offset to apply
34       * @return flattened curve
35       */
36      PolyLine2d flatten(OffsetCurve2d curve, ContinuousPiecewiseLinearFunction of);
37  
38      /**
39       * Load one knot in the map of fractions and points.
40       * @param map the map
41       * @param knot the fraction where the knot occurs
42       * @param curve the curve that can compute the point for each <code>knot</code> position
43       * @param of offset data
44       * @throws NullPointerException when <code>map</code> is <code>null</code>, <code>knot</code> is <code>null</code>,
45       *             <code>curve</code> is <code>null</code>, or <code>of</code> is <code>null</code>
46       * @throws IllegalArgumentException when <code>knot &lt; 0.0</code>, or <code>knot &gt; 1.0</code>
47       */
48      default void loadKnot(final NavigableMap<Double, Point2d> map, final double knot, final OffsetCurve2d curve,
49              final ContinuousPiecewiseLinearFunction of)
50      {
51          Throw.when(knot < 0.0 || knot > 1.0, IllegalArgumentException.class, "Knots must all be between 0.0 and 1.0, (got %f)",
52                  knot);
53          double t = curve.getT(knot * curve.getLength());
54          if (map.containsKey(t))
55          {
56              return;
57          }
58          map.put(t, curve.getPoint(t, of));
59      }
60  
61      /**
62       * Load the knots into the navigable map (including the start point and the end point).
63       * @param map the navigable map
64       * @param curve the curve
65       * @param of the offset data
66       */
67      default void loadKnots(final NavigableMap<Double, Point2d> map, final OffsetCurve2d curve,
68              final ContinuousPiecewiseLinearFunction of)
69      {
70          map.put(0.0, curve.getPoint(0.0, of));
71          Set<Double> knots = curve.getKnots();
72          if (null != knots)
73          {
74              for (double knot : knots)
75              {
76                  loadKnot(map, knot, curve, of);
77              }
78          }
79          for (ContinuousPiecewiseLinearFunction.TupleSt knot : of)
80          {
81              loadKnot(map, knot.s(), curve, of);
82          }
83          map.put(1.0, curve.getPoint(1.0, of));
84      }
85  
86      @Override
87      default boolean checkLoopBack(final Double prevDirection, final Double nextDirection)
88      {
89          return Math.abs(AngleUtil.normalizeAroundZero(nextDirection - prevDirection)) > Math.PI / 2;
90      }
91  
92      @Override
93      default boolean checkDirectionError(final Double segmentDirection, final Double curveDirectionAtStart,
94              final Double curveDirectionAtEnd, final double maxDirectionDeviation)
95      {
96          return (Math.abs(AngleUtil.normalizeAroundZero(segmentDirection - curveDirectionAtStart)) > maxDirectionDeviation)
97                  || Math.abs(AngleUtil.normalizeAroundZero(segmentDirection - curveDirectionAtEnd)) >= maxDirectionDeviation;
98      }
99  
100     /**
101      * Make a FlattableCurve object.
102      * @param curve the curve
103      * @param of the offset function
104      * @return the flattable curve
105      */
106     default Flattener.FlattableCurve<Point2d, Double> makeFlattableCurve(final OffsetCurve2d curve,
107             final ContinuousPiecewiseLinearFunction of)
108     {
109         return new Flattener.FlattableCurve<Point2d, Double>()
110         {
111             @Override
112             public Point2d getPoint(final double fraction)
113             {
114                 return curve.getPoint(fraction, of);
115             }
116 
117             @Override
118             public Double getDirection(final double fraction)
119             {
120                 return curve.getDirection(fraction, of);
121             }
122         };
123     }
124 
125     /**
126      * Flattener that approximates the <code>OffsetCurve2d</code> with a specified number of segments.
127      */
128     class NumSegments implements OffsetFlattener2d
129     {
130         /** Number of segments. */
131         private final int numSegments;
132 
133         /**
134          * Construct a flattener that approximates the <code>OffsetCurve2d</code> with a specified number of segments.
135          * @param numSegments number of segments to use in the construction of the <code>PolyLine2d</code>
136          * @throws IllegalArgumentException when <code>numSegments &lt; 1</code>
137          */
138         public NumSegments(final int numSegments)
139         {
140             Throw.when(numSegments < 1, IllegalArgumentException.class, "Number of segments must be at least 1.");
141             this.numSegments = numSegments;
142         }
143 
144         @Override
145         public PolyLine2d flatten(final OffsetCurve2d curve, final ContinuousPiecewiseLinearFunction of)
146         {
147             Throw.whenNull(curve, "curve");
148             List<Point2d> points = new ArrayList<>(this.numSegments + 1);
149             for (int i = 0; i <= this.numSegments; i++)
150             {
151                 double fraction = ((double) i) / this.numSegments;
152                 points.add(curve.getPoint(fraction, of));
153             }
154             return new PolyLine2d(points);
155         }
156     }
157 
158     /**
159      * Flattener that limits the distance between the <code>OffsetCurvee2d</code> and the <code>PolyLine2d</code>.
160      */
161     class MaxDeviation implements OffsetFlattener2d
162     {
163         /** Maximum deviation. */
164         private final double maxDeviation;
165 
166         /**
167          * Construct an OffsetFlattener that limits the distance between the <code>OffsetCurve2d</code> and the
168          * <code>PolyLine2d</code>.
169          * @param maxDeviation maximum deviation, must be above 0.0
170          * @throws ArithmeticException when <code>maxDeviation</code> is <code>NaN</code>
171          * @throws IllegalArgumentException when <code>maxDeviation &le; 0.0</code>
172          */
173         public MaxDeviation(final double maxDeviation)
174         {
175             Throw.whenNaN(maxDeviation, "maxDeviation");
176             Throw.when(maxDeviation <= 0.0, IllegalArgumentException.class, "Maximum deviation must be above 0.0 and finite");
177             this.maxDeviation = maxDeviation;
178         }
179 
180         @Override
181         public PolyLine2d flatten(final OffsetCurve2d curve, final ContinuousPiecewiseLinearFunction of)
182         {
183             Throw.whenNull(curve, "curve");
184             Flattener.FlattableCurve<Point2d, Double> fc = makeFlattableCurve(curve, of);
185             NavigableMap<Double, Point2d> result = new TreeMap<>();
186             loadKnots(result, curve, of);
187 
188             // Walk along all point pairs and see if additional points need to be inserted
189             double prevT = result.firstKey();
190             Point2d prevPoint = result.get(prevT);
191             Map.Entry<Double, Point2d> entry;
192             while ((entry = result.higherEntry(prevT)) != null)
193             {
194                 double nextT = entry.getKey();
195                 Point2d nextPoint = entry.getValue();
196                 double medianT = (prevT + nextT) / 2;
197                 Point2d medianPoint = curve.getPoint(medianT, of);
198                 // Check max deviation
199                 if (checkPositionError(medianPoint, prevPoint, nextPoint, this.maxDeviation))
200                 {
201                     // We need to insert another point
202                     result.put(medianT, medianPoint);
203                     continue;
204                 }
205                 if (prevPoint.distance(nextPoint) > this.maxDeviation
206                         && checkInflectionPoint(fc, prevT, medianT, nextT, prevPoint, nextPoint))
207                 {
208                     // There is an inflection point, inserting the halfway point should take care of this
209                     result.put(medianT, medianPoint);
210                     continue;
211                 }
212                 if (checkLoopBack(curve.getDirection(prevT), curve.getDirection(nextT)))
213                 {
214                     // The curve loops back onto itself. Inserting the halfway point should prevent missing out a major detour
215                     // This check is NOT needed in the MaxDeviationAndAngle flattener.
216                     result.put(medianT, medianPoint);
217                     continue;
218                 }
219                 prevT = nextT;
220                 prevPoint = nextPoint;
221             }
222             return new PolyLine2d(result.values().iterator());
223         }
224     }
225 
226     /**
227      * OffsetFlattener that limits distance <b>and</b> angle difference between the <code>OffsetCurve2d</code> and the
228      * <code>PolyLine2d</code>.
229      */
230     class MaxDeviationAndAngle implements OffsetFlattener2d
231     {
232         /** Maximum deviation. */
233         private final double maxDeviation;
234 
235         /** Maximum angle. */
236         private final double maxAngle;
237 
238         /**
239          * Construct a flattener that limits distance <b>and</b> angle difference between the <code>OffsetCurve2d</code> and the
240          * <code>PolyLine2d</code>.
241          * @param maxDeviation maximum deviation, must be above 0.0
242          * @param maxAngle maximum angle, must be above 0.0
243          * @throws ArithmeticException when <code>maxDeviation</code>, or <code>maxAngle</code> is <code>NaN</code>
244          * @throws IllegalArgumentException when <code>maxDeviation &le; 0.0</code>, or <code>maxAngle &le; 0.0</code>
245          */
246         public MaxDeviationAndAngle(final double maxDeviation, final double maxAngle)
247         {
248             Throw.whenNaN(maxDeviation, "maxDeviation");
249             Throw.whenNaN(maxAngle, "maxAngle");
250             Throw.when(maxDeviation <= 0.0, IllegalArgumentException.class, "Maximum deviation must be above 0.0.");
251             Throw.when(maxAngle <= 0.0, IllegalArgumentException.class, "Maximum angle must be above 0.0.");
252             this.maxDeviation = maxDeviation;
253             this.maxAngle = maxAngle;
254         }
255 
256         @Override
257         public PolyLine2d flatten(final OffsetCurve2d curve, final ContinuousPiecewiseLinearFunction of)
258         {
259             Throw.whenNull(curve, "curve");
260             Flattener.FlattableCurve<Point2d, Double> fc = makeFlattableCurve(curve, of);
261             NavigableMap<Double, Point2d> result = new TreeMap<>();
262             loadKnots(result, curve, of);
263             Map<Double, Double> directions = new LinkedHashMap<>();
264             directions.put(0.0, curve.getDirection(0.0, of)); // directions can't do ULP before 0.0
265             Set<Double> knots = new HashSet<>();
266             for (double knot : result.keySet())
267             {
268                 if (knot > 0)
269                 {
270                     directions.put(knot, curve.getDirection(knot - Math.ulp(knot), of));
271                 }
272                 if (knot != 0.0 && knot != 1.0)
273                 {
274                     knots.add(knot);
275                 }
276             }
277 
278             // Walk along all point pairs and see if additional points need to be inserted
279             double prevT = result.firstKey();
280             Point2d prevPoint = result.get(prevT);
281             Map.Entry<Double, Point2d> entry;
282             int iterationsAtSinglePoint = 0;
283             while ((entry = result.higherEntry(prevT)) != null)
284             {
285                 double nextT = entry.getKey();
286                 Point2d nextPoint = entry.getValue();
287                 double medianT = (prevT + nextT) / 2;
288                 Point2d medianPoint = curve.getPoint(medianT, of);
289                 // Check max deviation
290                 if (checkPositionError(medianPoint, prevPoint, nextPoint, this.maxDeviation))
291                 {
292                     // We need to insert another point
293                     result.put(medianT, medianPoint);
294                     directions.put(medianT, curve.getDirection(medianT, of));
295                     continue;
296                 }
297                 // Check max angle
298                 if (checkDirectionError(prevPoint.directionTo(nextPoint), directions.get(prevT), directions.get(nextT),
299                         this.maxAngle))
300                 {
301                     // We need to insert another point
302                     result.put(medianT, medianPoint);
303                     directions.put(medianT, curve.getDirection(medianT, of));
304                     iterationsAtSinglePoint++;
305                     Throw.when(iterationsAtSinglePoint == 50, IllegalArgumentException.class, "Required a new point 50 times "
306                             + "around the same point (t=%f). Likely there is an (unreported) knot in the OffsetCurve2d.",
307                             medianT);
308                     continue;
309                 }
310                 iterationsAtSinglePoint = 0;
311                 if (prevPoint.distance(nextPoint) > this.maxDeviation
312                         && checkInflectionPoint(fc, prevT, medianT, nextT, prevPoint, nextPoint))
313                 {
314                     // There is an inflection point, inserting the halfway point should take care of this
315                     result.put(medianT, medianPoint);
316                     directions.put(medianT, curve.getDirection(medianT, of));
317                     continue;
318                 }
319                 prevT = nextT;
320                 prevPoint = nextPoint;
321                 if (prevT < 1.0 && knots.contains(prevT))
322                 {
323                     directions.put(prevT, curve.getDirection(prevT + Math.ulp(prevT), of));
324                 }
325             }
326             return new PolyLine2d(result.values().iterator());
327         }
328     }
329 
330     /**
331      * OffsetFlattener that limits the angle difference between the <code>OffsetCurve2d</code> and the <code>PolyLine2d</code>.
332      */
333     class MaxAngle implements OffsetFlattener2d
334     {
335         /** Maximum angle. */
336         private final double maxAngle;
337 
338         /**
339          * Construct a flattener that limits the angle difference between the <code>OffsetCurve2d</code> and the
340          * <code>PolyLine2d</code>.
341          * @param maxAngle maximum angle.
342          * @throws ArithmeticException when <code>maxAngle</code> is <code>NaN</code>
343          * @throws IllegalArgumentException when <code>maxAngle &le; 0.0</code>
344          */
345         public MaxAngle(final double maxAngle)
346         {
347             Throw.whenNaN(maxAngle, "maxAngle");
348             Throw.when(maxAngle <= 0.0, IllegalArgumentException.class, "Maximum angle must be above 0.0.");
349             this.maxAngle = maxAngle;
350         }
351 
352         @Override
353         public PolyLine2d flatten(final OffsetCurve2d curve, final ContinuousPiecewiseLinearFunction of)
354         {
355             Throw.whenNull(curve, "curve");
356             Flattener.FlattableCurve<Point2d, Double> fc = makeFlattableCurve(curve, of);
357             NavigableMap<Double, Point2d> result = new TreeMap<>();
358             loadKnots(result, curve, of);
359             Map<Double, Double> directions = new LinkedHashMap<>();
360             directions.put(0.0, curve.getDirection(0.0, of)); // directions can't do ULP before 0.0
361             Set<Double> knots = new HashSet<>();
362             for (double knot : result.keySet())
363             {
364                 if (knot > 0)
365                 {
366                     directions.put(knot, curve.getDirection(knot - Math.ulp(knot), of));
367                 }
368                 if (knot != 0.0 && knot != 1.0)
369                 {
370                     knots.add(knot);
371                 }
372             }
373 
374             // Walk along all point pairs and see if additional points need to be inserted
375             double prevT = result.firstKey();
376             Point2d prevPoint = result.get(prevT);
377             Map.Entry<Double, Point2d> entry;
378             int iterationsAtSinglePoint = 0;
379             while ((entry = result.higherEntry(prevT)) != null)
380             {
381                 double nextT = entry.getKey();
382                 Point2d nextPoint = entry.getValue();
383                 double medianT = (prevT + nextT) / 2;
384 
385                 // Check max angle
386                 if (checkDirectionError(prevPoint.directionTo(nextPoint), directions.get(prevT), directions.get(nextT),
387                         this.maxAngle))
388                 {
389                     // We need to insert another point
390                     Point2d medianPoint = curve.getPoint(medianT, of);
391                     result.put(medianT, medianPoint);
392                     directions.put(medianT, curve.getDirection(medianT, of));
393                     iterationsAtSinglePoint++;
394                     Throw.when(iterationsAtSinglePoint == 50, IllegalArgumentException.class, "Required a new point 50 "
395                             + "times around the same point (t=%f). Likely there is an (unreported) knot in the OffsetCurve2d.",
396                             medianT);
397                     continue;
398                 }
399                 iterationsAtSinglePoint = 0;
400                 if (checkInflectionPoint(fc, prevT, medianT, nextT, prevPoint, nextPoint))
401                 {
402                     // There is an inflection point, inserting the halfway point should take care of this
403                     Point2d medianPoint = curve.getPoint(medianT, of);
404                     result.put(medianT, medianPoint);
405                     directions.put(medianT, curve.getDirection(medianT, of));
406                     continue;
407                 }
408                 prevT = nextT;
409                 prevPoint = nextPoint;
410                 if (knots.contains(prevT))
411                 {
412                     directions.put(prevT, curve.getDirection(prevT + Math.ulp(prevT), of));
413                 }
414             }
415             return new PolyLine2d(result.values().iterator());
416         }
417     }
418 
419 }