View Javadoc
1   package org.opentrafficsim.core.geometry;
2   
3   import java.util.ArrayList;
4   import java.util.Arrays;
5   import java.util.Iterator;
6   import java.util.List;
7   import java.util.Map.Entry;
8   import java.util.NavigableMap;
9   import java.util.NavigableSet;
10  import java.util.Set;
11  import java.util.SortedSet;
12  import java.util.TreeMap;
13  import java.util.TreeSet;
14  
15  import org.djutils.draw.line.PolyLine2d;
16  import org.djutils.draw.point.OrientedPoint2d;
17  import org.djutils.draw.point.Point2d;
18  import org.djutils.exceptions.Throw;
19  
20  /**
21   * Continuous definition of a cubic Bezier. This extends from the more general {@code ContinuousBezier} as certain methods are
22   * applied to calculate e.g. the roots, that are specific to cubic Beziers. With such information this class can also specify
23   * information to be a {@code ContinuousLine}.
24   * <p>
25   * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
26   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
27   * </p>
28   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
29   * @see <a href="https://pomax.github.io/bezierinfo/">Bezier info</a>
30   */
31  public class ContinuousBezierCubic extends ContinuousBezier implements ContinuousLine
32  {
33  
34      /** Angle below which segments are seen as straight. */
35      private static final double STRAIGHT = Math.PI / 36000; // 1/100th of a degree
36  
37      /** Start point with direction. */
38      private final OrientedPoint2d startPoint;
39  
40      /** End point with direction. */
41      private final OrientedPoint2d endPoint;
42  
43      /** Length. */
44      private final double length;
45  
46      /**
47       * Create a cubic Bezier.
48       * @param point1 start point.
49       * @param point2 first intermediate shape point.
50       * @param point3 second intermediate shape point.
51       * @param point4 end point.
52       */
53      public ContinuousBezierCubic(final Point2d point1, final Point2d point2, final Point2d point3, final Point2d point4)
54      {
55          super(point1, point2, point3, point4);
56          this.startPoint = new OrientedPoint2d(point1.x, point1.y, Math.atan2(point2.y - point1.y, point2.x - point1.x));
57          this.endPoint = new OrientedPoint2d(point4.x, point4.y, Math.atan2(point4.y - point3.y, point4.x - point3.x));
58          this.length = length();
59      }
60  
61      @Override
62      public OrientedPoint2d getStartPoint()
63      {
64          return this.startPoint;
65      }
66  
67      @Override
68      public OrientedPoint2d getEndPoint()
69      {
70          return this.endPoint;
71      }
72  
73      @Override
74      public double getStartCurvature()
75      {
76          return curvature(0.0);
77      }
78  
79      @Override
80      public double getEndCurvature()
81      {
82          return curvature(1.0);
83      }
84  
85      /**
86       * Returns the root t values, where each of the sub-components derivative for x and y are 0.0.
87       * @return set of root t values, sorted and in the range (0, 1), exclusive.
88       */
89      private SortedSet<Double> getRoots()
90      {
91          // Uses quadratic Bezier formulation
92          double ax = 3.0 * (-this.points[0].x + 3.0 * this.points[1].x - 3.0 * this.points[2].x + this.points[3].x);
93          double ay = 3.0 * (-this.points[0].y + 3.0 * this.points[1].y - 3.0 * this.points[2].y + this.points[3].y);
94          double bx = 6.0 * (this.points[0].x - 2.0 * this.points[1].x + this.points[2].x);
95          double by = 6.0 * (this.points[0].y - 2.0 * this.points[1].y + this.points[2].y);
96          double cx = 3.0 * (this.points[1].x - this.points[0].x);
97          double cy = 3.0 * (this.points[1].y - this.points[0].y);
98  
99          // ABC formula
100         TreeSet<Double> roots = new TreeSet<>();
101         double g = bx * bx - 4.0 * ax * cx;
102         if (g > 0)
103         {
104             double sqrtg = Math.sqrt(g);
105             double ax2 = 2.0 * ax;
106             roots.add((-bx + sqrtg) / ax2);
107             roots.add((-bx - sqrtg) / ax2);
108         }
109         g = by * by - 4.0 * ay * cy;
110         if (g > 0)
111         {
112             double sqrtg = Math.sqrt(g);
113             double ay2 = 2.0 * ay;
114             roots.add((-by + sqrtg) / ay2);
115             roots.add((-by - sqrtg) / ay2);
116         }
117 
118         // Only roots in range (0.0 ... 1.0) are valid and useful
119         return roots.subSet(0.0, false, 1.0, false);
120     }
121 
122     /**
123      * Returns the inflection t values, where curvature changes sign.
124      * @return set of inflection t values, sorted and in the range (0, 1), exclusive.
125      */
126     private SortedSet<Double> getInflections()
127     {
128         // Align: translate so first point is (0, 0), rotate so last point is on x=axis (y = 0)
129         Point2d[] aligned = new Point2d[4];
130         double ang = -Math.atan2(this.points[3].y - this.points[0].y, this.points[3].x - this.points[0].x);
131         double cosAng = Math.cos(ang);
132         double sinAng = Math.sin(ang);
133         for (int i = 0; i < 4; i++)
134         {
135             aligned[i] =
136                     new Point2d(cosAng * (this.points[i].x - this.points[0].x) - sinAng * (this.points[i].y - this.points[0].y),
137                             sinAng * (this.points[i].x - this.points[0].x) + cosAng * (this.points[i].y - this.points[0].y));
138         }
139 
140         // Inflection as curvature = 0, using:
141         // curvature = x'(t)*y''(t) + y'(t)*x''(t) = 0
142         // (this is highly simplified due to the alignment, removing many terms)
143         double a = aligned[2].x * aligned[1].y;
144         double b = aligned[3].x * aligned[1].y;
145         double c = aligned[1].x * aligned[2].y;
146         double d = aligned[3].x * aligned[2].y;
147 
148         double x = -3.0 * a + 2.0 * b + 3.0 * c - d;
149         double y = 3.0 * a - b - 3.0 * c;
150         double z = c - a;
151 
152         // ABC formula (on x, y, z)
153         TreeSet<Double> inflections = new TreeSet<>();
154         if (Math.abs(x) < 1.0e-6)
155         {
156             if (Math.abs(y) >= 1.0e-12)
157             {
158                 inflections.add(-z / y);
159             }
160         }
161         else
162         {
163             double det = y * y - 4.0 * x * z;
164             double sq = Math.sqrt(det);
165             double d2 = 2 * x;
166             if (det >= 0.0 && Math.abs(d2) >= 1e-12)
167             {
168                 inflections.add(-(y + sq) / d2);
169                 inflections.add((sq - y) / d2);
170             }
171         }
172 
173         // Only inflections in range (0.0 ... 1.0) are valid and useful
174         return inflections.subSet(0.0, false, 1.0, false);
175     }
176 
177     /**
178      * Returns the offset t values.
179      * @param fractions length fractions at which offsets are defined.
180      * @return set of offset t values, sorted and in the range (0, 1), exclusive.
181      */
182     private SortedSet<Double> getOffsetT(final Set<Double> fractions)
183     {
184         TreeSet<Double> crossSections = new TreeSet<>();
185         double lenTot = length();
186         for (Double f : fractions)
187         {
188             if (f > 0.0 && f < 1.0)
189             {
190                 crossSections.add(getT(f * lenTot));
191             }
192         }
193         return crossSections;
194     }
195 
196     /**
197      * Returns the t value at the provided length along the Bezier. This method uses an iterative approach with a precision of
198      * 1e-6.
199      * @param len length along the Bezier.
200      * @return t value at the provided length along the Bezier.
201      */
202     public double getT(final double len)
203     {
204         // start at 0.0 and 1.0, cut in half, see which half to use next
205         double t0 = 0.0;
206         double t2 = 1.0;
207         double t1 = 0.5;
208         while (t2 > t0 + 1.0e-6)
209         {
210             t1 = (t2 + t0) / 2.0;
211             ContinuousBezierCubic[] parts = split(t1);
212             double len1 = parts[0].length();
213             if (len1 < len)
214             {
215                 t0 = t1;
216             }
217             else
218             {
219                 t2 = t1;
220             }
221         }
222         return t1;
223     }
224 
225     /**
226      * Splits the Bezier in two Beziers of the same order.
227      * @param t t value along the Bezier to apply the split.
228      * @return the Bezier before t, and the Bezier after t.
229      */
230     public ContinuousBezierCubic[] split(final double t)
231     {
232         Throw.when(t < 0.0 || t > 1.0, IllegalArgumentException.class, "t value should be in the range [0.0 ... 1.0].");
233         List<Point2d> p1 = new ArrayList<>();
234         List<Point2d> p2 = new ArrayList<>();
235         split0(t, List.of(this.points), p1, p2);
236         return new ContinuousBezierCubic[] {new ContinuousBezierCubic(p1.get(0), p1.get(1), p1.get(2), p1.get(3)),
237                 new ContinuousBezierCubic(p2.get(3), p2.get(2), p2.get(1), p2.get(0))};
238     }
239 
240     /**
241      * Performs the iterative algorithm of Casteljau to derive the split Beziers.
242      * @param t t value along the Bezier to apply the split.
243      * @param p shape points of Bezier still to split.
244      * @param p1 shape points of first part, accumulated in the recursion.
245      * @param p2 shape points of first part, accumulated in the recursion.
246      */
247     private void split0(final double t, final List<Point2d> p, final List<Point2d> p1, final List<Point2d> p2)
248     {
249         if (p.size() == 1)
250         {
251             p1.add(p.get(0));
252             p2.add(p.get(0));
253         }
254         else
255         {
256             List<Point2d> pNew = new ArrayList<>();
257             for (int i = 0; i < p.size() - 1; i++)
258             {
259                 if (i == 0)
260                 {
261                     p1.add(p.get(i));
262                 }
263                 if (i == p.size() - 2)
264                 {
265                     p2.add(p.get(i + 1));
266                 }
267                 double t1 = 1.0 - t;
268                 pNew.add(new Point2d(t1 * p.get(i).x + t * p.get(i + 1).x, t1 * p.get(i).y + t * p.get(i + 1).y));
269             }
270             split0(t, pNew, p1, p2);
271         }
272     }
273 
274     @Override
275     public PolyLine2d flatten(final Flattener flattener)
276     {
277         Throw.whenNull(flattener, "Flattener may not be null.");
278         return flattener.flatten(new FlattableLine()
279         {
280             @Override
281             public Point2d get(final double fraction)
282             {
283                 return at(fraction);
284             }
285 
286             @Override
287             public double getDirection(final double fraction)
288             {
289                 Point2d derivative = derivative().at(fraction);
290                 return Math.atan2(derivative.y, derivative.x);
291             }
292         });
293     }
294 
295     @Override
296     public PolyLine2d flattenOffset(final ContinuousDoubleFunction offset, final Flattener flattener)
297     {
298         Throw.whenNull(offset, "Offsets may not be null.");
299         Throw.whenNull(flattener, "Flattener may not be null.");
300 
301         // Detect straight line
302         double ang1 = Math.atan2(this.points[1].y - this.points[0].y, this.points[1].x - this.points[0].x);
303         double ang2 = Math.atan2(this.points[3].y - this.points[1].y, this.points[3].x - this.points[1].x);
304         double ang3 = Math.atan2(this.points[3].y - this.points[2].y, this.points[3].x - this.points[2].x);
305         boolean straight =
306                 Math.abs(ang1 - ang2) < STRAIGHT && Math.abs(ang2 - ang3) < STRAIGHT && Math.abs(ang3 - ang1) < STRAIGHT;
307 
308         /*
309          * A Bezier does not have a trivial offset. Hence, we split the Bezier along points of 3 types. 1) roots, where the
310          * derivative of either the x-component or y-component is 0, such that we obtain C-shaped scalable segments, 2)
311          * inflections, where the curvature changes sign and the offset and offset angle need to flip sign, and 3) offset
312          * fractions so that the intended offset segments can be adhered to. Note that C-shaped segments can be scaled similar
313          * to a circle arc, whereas S-shaped segments have no trivial scaling and are thus split.
314          */
315         NavigableMap<Double, ContinuousBezierCubic> segments = new TreeMap<>();
316 
317         // Gather all points to split segments, and their types (1=root, 2=inflection, or 3=offset fraction)
318         NavigableMap<Double, Integer> splits0 = new TreeMap<>(); // splits0 & splits because splits0 must be effectively final
319         if (!straight)
320         {
321             getRoots().forEach((t) -> splits0.put(t, 1));
322             getInflections().forEach((t) -> splits0.put(t, 2));
323         }
324         NavigableSet<Double> knots = new TreeSet<>();
325         for (double f : offset.getKnots())
326         {
327             knots.add(f);
328         }
329         getOffsetT(knots).forEach((t) -> splits0.put(t, 3));
330         NavigableMap<Double, Integer> splits = splits0.subMap(1e-6, false, 1.0 - 1e-6, false);
331 
332         // Initialize loop variables
333         // copy of offset fractions, so we can remove each we use; exclude 0.0 value to find split points -on- Bezier
334         NavigableSet<Double> fCrossSectionRemain = knots.tailSet(0.0, false);
335         double lengthTotal = length();
336         ContinuousBezierCubic currentBezier = this;
337         double lengthSoFar = 0.0;
338         boolean first = true;
339         // curvature and angle sign, flips at each inflection, start based on initial curve
340         double sig = Math.signum((this.points[1].y - this.points[0].y) * (this.points[2].x - this.points[0].x)
341                 - (this.points[1].x - this.points[0].x) * (this.points[2].y - this.points[0].y));
342 
343         Iterator<Double> typeIterator = splits.navigableKeySet().iterator();
344         double tStart = 0.0;
345         if (splits.isEmpty())
346         {
347             segments.put(tStart, currentBezier.offset(offset, lengthSoFar, lengthTotal, sig, first, true));
348         }
349         while (typeIterator.hasNext())
350         {
351 
352             double tInFull = typeIterator.next();
353             int type = splits.get(tInFull);
354             boolean isRoot = type == 1;
355             boolean isInflection = type == 2;
356             // boolean isOffsetFraction = type == 3;
357             double t;
358             // Note: as we split the Bezier and work with the remainder in each loop, the resulting t value is not the same as
359             // on the full Bezier. Therefore we need to refind the roots, or inflections, or at least one cross-section.
360             if (isRoot)
361             {
362                 t = currentBezier.getRoots().first();
363             }
364             else if (isInflection)
365             {
366                 t = currentBezier.getInflections().first();
367             }
368             else
369             {
370                 NavigableSet<Double> fCrossSection = new TreeSet<>();
371                 double fSoFar = lengthSoFar / lengthTotal;
372                 double fFirst = fCrossSectionRemain.pollFirst(); // fraction in total Bezier
373                 fCrossSection.add((fFirst - fSoFar) / (1.0 - fSoFar)); // add fraction in remaining Bezier
374                 t = currentBezier.getOffsetT(fCrossSection).first();
375             }
376 
377             // Split Bezier, and add offset of first part
378             ContinuousBezierCubic[] parts = currentBezier.split(t);
379             segments.put(tStart, parts[0].offset(offset, lengthSoFar, lengthTotal, sig, first, false));
380 
381             // Update loop variables
382             first = false;
383             lengthSoFar += parts[0].getLength();
384             if (isInflection)
385             {
386                 sig = -sig;
387             }
388             tStart = tInFull;
389 
390             // Append last segment, or loop again with remainder
391             if (!typeIterator.hasNext())
392             {
393                 segments.put(tStart, parts[1].offset(offset, lengthSoFar, lengthTotal, sig, first, true));
394             }
395             else
396             {
397                 currentBezier = parts[1];
398             }
399         }
400         segments.put(1.0, null); // so we can interpolate t values along segments
401 
402         // Flatten with FlattableLine based on the offset segments created above
403         return flattener.flatten(new FlattableLine()
404         {
405             @Override
406             public Point2d get(final double fraction)
407             {
408                 Entry<Double, ContinuousBezierCubic> entry;
409                 double nextT;
410                 if (fraction == 1.0)
411                 {
412                     entry = segments.lowerEntry(fraction);
413                     nextT = fraction;
414                 }
415                 else
416                 {
417                     entry = segments.floorEntry(fraction);
418                     nextT = segments.higherKey(fraction);
419                 }
420                 double t = (fraction - entry.getKey()) / (nextT - entry.getKey());
421                 return entry.getValue().at(t);
422             }
423 
424             @Override
425             public double getDirection(final double fraction)
426             {
427                 Entry<Double, ContinuousBezierCubic> entry = segments.floorEntry(fraction);
428                 if (entry.getValue() == null)
429                 {
430                     // end of line
431                     entry = segments.lowerEntry(fraction);
432                     Point2d derivative = entry.getValue().derivative().at(1.0);
433                     return Math.atan2(derivative.y, derivative.x);
434                 }
435                 Double nextT = segments.higherKey(fraction);
436                 if (nextT == null)
437                 {
438                     nextT = 1.0;
439                 }
440                 double t = (fraction - entry.getKey()) / (nextT - entry.getKey());
441                 Point2d derivative = entry.getValue().derivative().at(t);
442                 return Math.atan2(derivative.y, derivative.x);
443             }
444         });
445     }
446 
447     /**
448      * Creates the offset Bezier of a Bezier segment. These segments are part of the offset procedure.
449      * @param offset offsets as defined for entire Bezier.
450      * @param lengthSoFar total length of previous segments.
451      * @param lengthTotal total length of full Bezier.
452      * @param sig sign of offset and offset slope
453      * @param first {@code true} for the first Bezier segment.
454      * @param last {@code true} for the last Bezier segment.
455      * @return offset Bezier.
456      */
457     private ContinuousBezierCubic offset(final ContinuousDoubleFunction offset, final double lengthSoFar, final double lengthTotal,
458             final double sig, final boolean first, final boolean last)
459     {
460         double offsetStart = sig * offset.apply(lengthSoFar / lengthTotal);
461         double offsetEnd = sig * offset.apply((lengthSoFar + getLength()) / lengthTotal);
462 
463         Point2d p1 = new Point2d(this.points[0].x - (this.points[1].y - this.points[0].y),
464                 this.points[0].y + (this.points[1].x - this.points[0].x));
465         Point2d p2 = new Point2d(this.points[3].x - (this.points[2].y - this.points[3].y),
466                 this.points[3].y + (this.points[2].x - this.points[3].x));
467         Point2d center = Point2d.intersectionOfLines(this.points[0], p1, p2, this.points[3]);
468 
469         if (center == null)
470         {
471             // start and end have same direction, offset first two by same amount, and last two by same amount, to maintain dirs
472             Point2d[] newBezierPoints = new Point2d[4];
473             double ang = Math.atan2(p1.y - this.points[0].y, p1.x - this.points[0].x);
474             double dxStart = Math.cos(ang) * offsetStart;
475             double dyStart = -Math.sin(ang) * offsetStart;
476             newBezierPoints[0] = new Point2d(this.points[0].x + dxStart, this.points[0].y + dyStart);
477             newBezierPoints[1] = new Point2d(this.points[1].x + dxStart, this.points[1].y + dyStart);
478             double dxEnd = Math.cos(ang) * offsetEnd;
479             double dyEnd = -Math.sin(ang) * offsetEnd;
480             newBezierPoints[2] = new Point2d(this.points[2].x + dxEnd, this.points[2].y + dyEnd);
481             newBezierPoints[3] = new Point2d(this.points[3].x + dxEnd, this.points[3].y + dyEnd);
482             return new ContinuousBezierCubic(newBezierPoints[0], newBezierPoints[1], newBezierPoints[2], newBezierPoints[3]);
483         }
484 
485         // move 1st and 4th point their respective offsets away from the center
486         Point2d[] newBezierPoints = new Point2d[4];
487         double off = offsetStart;
488         for (int i = 0; i < 4; i = i + 3)
489         {
490             double dy = this.points[i].y - center.y;
491             double dx = this.points[i].x - center.x;
492             double ang = Math.atan2(dy, dx);
493             double len = Math.hypot(dx, dy) + off;
494             newBezierPoints[i] = new Point2d(center.x + len * Math.cos(ang), center.y + len * Math.sin(ang));
495             off = offsetEnd;
496         }
497 
498         // find tangent unit vectors that account for slope in offset
499         double ang = sig * Math.atan((offsetEnd - offsetStart) / getLength());
500         double cosAng = Math.cos(ang);
501         double sinAng = Math.sin(ang);
502         double dx = this.points[1].x - this.points[0].x;
503         double dy = this.points[1].y - this.points[0].y;
504         double dx1;
505         double dy1;
506         if (first)
507         {
508             // force same start angle
509             dx1 = dx;
510             dy1 = dy;
511         }
512         else
513         {
514             // shift angle by 'ang'
515             dx1 = cosAng * dx - sinAng * dy;
516             dy1 = sinAng * dx + cosAng * dy;
517         }
518         dx = this.points[2].x - this.points[3].x;
519         dy = this.points[2].y - this.points[3].y;
520         double dx2;
521         double dy2;
522         if (last)
523         {
524             // force same end angle
525             dx2 = dx;
526             dy2 = dy;
527         }
528         else
529         {
530             // shift angle by 'ang'
531             dx2 = cosAng * dx - sinAng * dy;
532             dy2 = sinAng * dx + cosAng * dy;
533         }
534 
535         // control points 2 and 3 as intersections between tangent unit vectors and line through center and original point 2 and
536         // 3 in original Bezier
537         Point2d cp2 = new Point2d(newBezierPoints[0].x + dx1, newBezierPoints[0].y + dy1);
538         newBezierPoints[1] = Point2d.intersectionOfLines(newBezierPoints[0], cp2, center, this.points[1]);
539         Point2d cp3 = new Point2d(newBezierPoints[3].x + dx2, newBezierPoints[3].y + dy2);
540         newBezierPoints[2] = Point2d.intersectionOfLines(newBezierPoints[3], cp3, center, this.points[2]);
541 
542         // create offset Bezier
543         return new ContinuousBezierCubic(newBezierPoints[0], newBezierPoints[1], newBezierPoints[2], newBezierPoints[3]);
544     }
545 
546     @Override
547     public double getLength()
548     {
549         return this.length;
550     }
551 
552     @Override
553     public String toString()
554     {
555         return "ContinuousBezierCubic [points=" + Arrays.toString(this.points) + "]";
556     }
557 
558 }