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.Direction3d;
13  import org.djutils.draw.line.PolyLine3d;
14  import org.djutils.draw.point.Point3d;
15  import org.djutils.exceptions.Throw;
16  
17  /**
18   * Flattens a Curve3d in to a PolyLine3d.
19   * <p>
20   * Copyright (c) 2023-2025 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
21   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
22   * </p>
23   * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
24   * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
25   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
26   */
27  public interface Flattener3d extends Flattener<Flattener3d, Curve3d, PolyLine3d, Point3d, Direction3d>
28  {
29      /**
30       * Flatten a Curve3d into a PolyLine3d.
31       * @param curve the curve
32       * @return flattened line
33       * @throws NullPointerException when <code>curve</code> is <code>null</code>
34       */
35      PolyLine3d flatten(Curve3d curve);
36  
37      @Override
38      default boolean checkInflectionPoint(final FlattableCurve<Point3d, Direction3d> curve, final double prevT,
39              final double medianT, final double nextT, final Point3d prevPoint, final Point3d nextPoint)
40      {
41          Point3d oneQuarter = curve.getPoint((prevT + medianT) / 2);
42          Direction3d oneQDir = oneQuarter.directionTo(oneQuarter.closestPointOnSegment(prevPoint, nextPoint));
43          Point3d threeQuarter = curve.getPoint((nextT + medianT) / 2);
44          Direction3d threeQDir = threeQuarter.directionTo(threeQuarter.closestPointOnSegment(prevPoint, nextPoint));
45          Double angle = oneQDir.directionDifference(threeQDir);
46          return angle > Math.PI / 2; // Direction varies by more than 90 degrees
47      }
48  
49      @Override
50      default boolean checkLoopBack(final Direction3d prevDirection, final Direction3d nextDirection)
51      {
52          return prevDirection.directionDifference(nextDirection) > Math.PI / 2;
53      }
54  
55      @Override
56      default boolean checkDirectionError(final Direction3d segmentDirection, final Direction3d curveDirectionAtStart,
57              final Direction3d curveDirectionAtEnd, final double maxDirectionDeviation)
58      {
59          return (segmentDirection.directionDifference(curveDirectionAtStart) > maxDirectionDeviation
60                  || segmentDirection.directionDifference(curveDirectionAtEnd) > maxDirectionDeviation);
61      }
62  
63      /**
64       * Flattener that approximates the <code>Curve3d</code> with a specified number of segments.
65       */
66      class NumSegments implements Flattener3d
67      {
68          /** Number of segments. */
69          private final int numSegments;
70  
71          /**
72           * Construct a flattener that approximates the <code>Curve3d</code> with a specified number of segments.
73           * @param numSegments number of segments to use in the construction of the PolyLine3d.
74           * @throws IllegalArgumentException when <code>numSegments &lt; 1</code>
75           */
76          public NumSegments(final int numSegments)
77          {
78              Throw.when(numSegments < 1, IllegalArgumentException.class, "Number of segments must be at least 1");
79              this.numSegments = numSegments;
80          }
81  
82          @Override
83          public PolyLine3d flatten(final Curve3d curve) throws NullPointerException
84          {
85              Throw.whenNull(curve, "curve");
86              List<Point3d> points = new ArrayList<>(this.numSegments + 1);
87              for (int i = 0; i <= this.numSegments; i++)
88              {
89                  double fraction = ((double) i) / this.numSegments;
90                  points.add(curve.getPoint(fraction));
91              }
92              return new PolyLine3d(points);
93          }
94      }
95  
96      /**
97       * Flattener that limits the distance between the <code>Curve3d</code> and the <code>PolyLine3d</code>.
98       */
99      class MaxDeviation implements Flattener3d
100     {
101         /** Maximum deviation. */
102         private final double maxDeviation;
103 
104         /**
105          * Construct a flattener that limits the distance between the <code>Curve3d</code> and the <code>PolyLine3d</code>.
106          * @param maxDeviation maximum deviation, must be above 0.0.
107          * @throws ArithmeticException when <code>maxDeviation</code> is <code>NaN</code>
108          * @throws IllegalArgumentException when <code>maxDeviation &le; 0.0</code>
109          */
110         public MaxDeviation(final double maxDeviation)
111         {
112             Throw.whenNaN(maxDeviation, "maxDeviation");
113             Throw.when(maxDeviation <= 0.0, IllegalArgumentException.class, "Maximum deviation must be above 0.0 and finite");
114             this.maxDeviation = maxDeviation;
115         }
116 
117         @Override
118         public PolyLine3d flatten(final Curve3d curve)
119         {
120             Throw.whenNull(curve, "curve");
121             NavigableMap<Double, Point3d> result = new TreeMap<>();
122             loadKnots(result, curve);
123 
124             // Walk along all point pairs and see if additional points need to be inserted
125             double prevT = result.firstKey();
126             Point3d prevPoint = result.get(prevT);
127             Map.Entry<Double, Point3d> entry;
128             while ((entry = result.higherEntry(prevT)) != null)
129             {
130                 double nextT = entry.getKey();
131                 Point3d nextPoint = entry.getValue();
132                 double medianT = (prevT + nextT) / 2;
133                 Point3d medianPoint = curve.getPoint(medianT);
134                 // Check max deviation
135                 if (checkPositionError(medianPoint, prevPoint, nextPoint, this.maxDeviation))
136                 {
137                     // We need to insert another point
138                     result.put(medianT, medianPoint);
139                     continue;
140                 }
141                 if (prevPoint.distance(nextPoint) > this.maxDeviation
142                         && checkInflectionPoint(curve, prevT, medianT, nextT, prevPoint, nextPoint))
143                 {
144                     // There is an inflection point, inserting the halfway point should take care of this
145                     result.put(medianT, medianPoint);
146                     continue;
147                 }
148                 if (checkLoopBack(curve.getDirection(prevT), curve.getDirection(nextT)))
149                 {
150                     // The curve loops back onto itself. Inserting the halfway point should prevent missing out a major detour
151                     // This check is NOT needed in the MaxDeviationAndAngle flattener.
152                     result.put(medianT, medianPoint);
153                     continue;
154                 }
155                 prevT = nextT;
156                 prevPoint = nextPoint;
157             }
158             return new PolyLine3d(result.values().iterator());
159         }
160     }
161 
162     /**
163      * Flattener that limits the distance <b>and</b> angle difference between the <code>Curve3d</code> and the
164      * <code>PolyLine3d</code>.
165      */
166     class MaxDeviationAndAngle implements Flattener3d
167     {
168         /** Maximum deviation. */
169         private final double maxDeviation;
170 
171         /** Maximum angle. */
172         private final double maxAngle;
173 
174         /**
175          * Construct a flattener that limits the distance <b>and</b> angle difference between the <code>curve3d</code> and the
176          * <code>PolyLine3d</code>.
177          * @param maxDeviation maximum deviation, must be above 0.0
178          * @param maxAngle maximum angle, must be above 0.0
179          * @throws ArithmeticException when <code>maxDeviation</code>, or <code>maxAngle</code> is <code>NaN</code>
180          * @throws IllegalArgumentException when <code>maxDeviation &le; 0.0</code>, or <code>maxAngle &le; 0.0</code>
181          */
182         public MaxDeviationAndAngle(final double maxDeviation, final double maxAngle)
183         {
184             Throw.whenNaN(maxDeviation, "maxDeviation");
185             Throw.whenNaN(maxAngle, "maxAngle");
186             Throw.when(maxDeviation <= 0.0, IllegalArgumentException.class, "Maximum deviation must be above 0.0 and finite");
187             Throw.when(maxAngle <= 0.0, IllegalArgumentException.class, "Maximum angle must be above 0.0");
188             this.maxDeviation = maxDeviation;
189             this.maxAngle = maxAngle;
190         }
191 
192         @Override
193         public PolyLine3d flatten(final Curve3d curve) throws NullPointerException
194         {
195             NavigableMap<Double, Point3d> result = new TreeMap<>();
196             loadKnots(result, curve);
197             Map<Double, Direction3d> directions = new LinkedHashMap<>();
198             Set<Double> knots = new HashSet<>();
199             for (double fraction : result.keySet())
200             {
201                 directions.put(fraction, curve.getDirection(fraction));
202                 knots.add(fraction);
203             }
204 
205             // Walk along all point pairs and see if additional points need to be inserted
206             double prevT = result.firstKey();
207             Point3d prevPoint = result.get(prevT);
208             Map.Entry<Double, Point3d> entry;
209             int iterationsAtSinglePoint = 0;
210             while ((entry = result.higherEntry(prevT)) != null)
211             {
212                 double nextT = entry.getKey();
213                 Point3d nextPoint = entry.getValue();
214                 double medianT = (prevT + nextT) / 2;
215                 Point3d medianPoint = curve.getPoint(medianT);
216                 // Check max deviation
217                 if (checkPositionError(medianPoint, prevPoint, nextPoint, this.maxDeviation))
218                 {
219                     // We need to insert another point
220                     result.put(medianT, medianPoint);
221                     directions.put(medianT, curve.getDirection(medianT));
222                     continue;
223                 }
224                 // Check max angle
225                 if (checkDirectionError(prevPoint.directionTo(nextPoint), directions.get(prevT), directions.get(nextT),
226                         this.maxAngle))
227                 {
228                     // We need to insert another point
229                     result.put(medianT, medianPoint);
230                     directions.put(medianT, curve.getDirection(medianT));
231                     iterationsAtSinglePoint++;
232                     Throw.when(iterationsAtSinglePoint == 50, IllegalArgumentException.class,
233                             "Required a new point 50 times "
234                                     + "around the same point (t=%f). Likely there is an (unreported) knot in the Curve3d.",
235                             medianT);
236                     continue;
237                 }
238                 iterationsAtSinglePoint = 0;
239                 if (prevPoint.distance(nextPoint) > this.maxDeviation
240                         && checkInflectionPoint(curve, prevT, medianT, nextT, prevPoint, nextPoint))
241                 {
242                     // There is an inflection point, inserting the halfway point should take care of this
243                     result.put(medianT, medianPoint);
244                     directions.put(medianT, curve.getDirection(medianT));
245                     continue;
246                 }
247                 prevT = nextT;
248                 prevPoint = nextPoint;
249                 if (knots.contains(prevT))
250                 {
251                     directions.put(prevT, curve.getDirection(prevT + Math.ulp(prevT)));
252                 }
253             }
254             return new PolyLine3d(result.values().iterator());
255         }
256     }
257 
258     /**
259      * Flattener that limits the angle difference between the <code>Curve3d</code> and the <code>PolyLine3d</code>.
260      */
261     class MaxAngle implements Flattener3d
262     {
263         /** Maximum angle. */
264         private final double maxAngle;
265 
266         /**
267          * Flattener that limits the angle difference between the <code>Curve3d</code> and the <code>PolyLine3d</code>.
268          * @param maxAngle maximum angle
269          * @throws ArithmeticException when <code>maxAngle</code> is <code>NaN</code>
270          * @throws IllegalArgumentException when <code>maxAngle &le; 0.0</code>
271          */
272         public MaxAngle(final double maxAngle)
273         {
274             Throw.whenNaN(maxAngle, "maxAngle");
275             Throw.when(maxAngle <= 0.0, IllegalArgumentException.class, "Maximum angle must be above 0.0");
276             this.maxAngle = maxAngle;
277         }
278 
279         @Override
280         public PolyLine3d flatten(final Curve3d curve)
281         {
282             NavigableMap<Double, Point3d> result = new TreeMap<>();
283             loadKnots(result, curve);
284             Map<Double, Direction3d> directions = new LinkedHashMap<>();
285             directions.put(0.0, curve.getDirection(0.0)); // directions can't do ULP before 0.0
286             Set<Double> knots = new HashSet<>();
287             for (double knot : result.keySet())
288             {
289                 if (knot > 0)
290                 {
291                     directions.put(knot, curve.getDirection(knot - Math.ulp(knot)));
292                 }
293                 if (knot != 0.0 && knot != 1.0)
294                 {
295                     knots.add(knot);
296                 }
297             }
298 
299             // Walk along all point pairs and see if additional points need to be inserted
300             double prevT = result.firstKey();
301             Point3d prevPoint = result.get(prevT);
302             Map.Entry<Double, Point3d> entry;
303             int iterationsAtSinglePoint = 0;
304             while ((entry = result.higherEntry(prevT)) != null)
305             {
306                 double nextT = entry.getKey();
307                 Point3d nextPoint = entry.getValue();
308                 double medianT = (prevT + nextT) / 2;
309 
310                 // Check max angle
311                 if (checkDirectionError(prevPoint.directionTo(nextPoint), directions.get(prevT), directions.get(nextT),
312                         this.maxAngle))
313                 {
314                     // We need to insert another point
315                     Point3d medianPoint = curve.getPoint(medianT);
316                     result.put(medianT, medianPoint);
317                     directions.put(medianT, curve.getDirection(medianT));
318                     iterationsAtSinglePoint++;
319                     Throw.when(iterationsAtSinglePoint == 50, IllegalArgumentException.class,
320                             "Required a new point 50 times "
321                                     + "around the same point (t=%f). Likely there is an (unreported) knot in the Curve3d.",
322                             medianT);
323                     continue;
324                 }
325                 iterationsAtSinglePoint = 0;
326                 if (checkInflectionPoint(curve, prevT, medianT, nextT, prevPoint, nextPoint))
327                 {
328                     // There is an inflection point, inserting the halfway point should take care of this
329                     Point3d medianPoint = curve.getPoint(medianT);
330                     result.put(medianT, medianPoint);
331                     directions.put(medianT, curve.getDirection(medianT));
332                     continue;
333                 }
334                 prevT = nextT;
335                 prevPoint = nextPoint;
336                 if (knots.contains(prevT))
337                 {
338                     directions.put(prevT, curve.getDirection(prevT + Math.ulp(prevT)));
339                 }
340             }
341             return new PolyLine3d(result.values().iterator());
342         }
343     }
344 
345 }