View Javadoc
1   package org.opentrafficsim.base.geometry;
2   
3   import java.util.Optional;
4   
5   import org.djunits.value.vdouble.scalar.Length;
6   import org.djutils.draw.point.Point2d;
7   import org.djutils.exceptions.Throw;
8   
9   /**
10   * Computes signed curvature radii for an {@link OtsLine2d} using the fractional projection helpers. Positive radius means
11   * left-hand curvature in the design-line direction.
12   * <p>
13   * Rules:
14   * </p>
15   * <ul>
16   * <li>Radius at a vertex is the distance from the midpoint of the <em>shorter</em> adjacent edge along the perpendicular line
17   * to the intersection with the local angle-splitting ray (from helper).</li>
18   * <li>Projected radius at a fraction equals the minimum (by absolute value) of the radii at the adjacent vertices.</li>
19   * <li>If the polyline is straight throughout, returns NaN.</li>
20   * </ul>
21   * <p>
22   * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
23   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
24   * </p>
25   * @author Wouter Schakel
26   */
27  public final class RadiusCalculator2d
28  {
29  
30      /** Small epsilon used in intersection guard only. */
31      private static final double INTERSECTION_EPS = 1e-7;
32  
33      /** The line for which to compute radii. */
34      private final OtsLine2d line;
35  
36      /** Fractional projection helper; used with (null, null) directions for curvature. */
37      private final FractionalProjectionHelper fracHelper;
38  
39      /**
40       * Lazily computed per-vertex radii; valid for indices [1 .. size() - 2]. Other indices are not used.
41       */
42      private Length[] vertexRadii;
43  
44      /**
45       * Construct a radius calculator for a line.
46       * @param line the line
47       * @param fracHelper the fractional projection helper; if null, a new one is created
48       */
49      public RadiusCalculator2d(final OtsLine2d line, final FractionalProjectionHelper fracHelper)
50      {
51          this.line = line;
52          this.fracHelper = fracHelper != null ? fracHelper : new FractionalProjectionHelper(line);
53          this.vertexRadii = new Length[Math.max(0, line.size())];
54      }
55  
56      /**
57       * Returns the projected directional radius at a fraction in [0, 1]. Uses the minimum-by-absolute-value of the two adjacent
58       * vertex radii. If no curvature exists or degenerate, returns NaN.
59       * @param fraction fraction along the line, in [0, 1]
60       * @return signed radius at the fraction, empty if not defined
61       * @throws IllegalArgumentException if fraction out of bounds
62       */
63      public synchronized Optional<Length> radiusAtFraction(final double fraction) throws IllegalArgumentException
64      {
65          Throw.when(fraction < 0.0 || fraction > 1.0, IllegalArgumentException.class,
66                  "Fraction %s is out of bounds [0.0 .. 1.0]", fraction);
67  
68          final int n = this.line.size() - 1; // number of segments
69          if (n < 2)
70          {
71              // fewer than two segments -> no vertex with curvature
72              return Optional.empty();
73          }
74  
75          final double totalLen = this.line.lengthAtIndex(this.line.size() - 1);
76          final double absS = fraction * totalLen;
77  
78          final int segIndex = segmentIndexAt(absS);
79          // Ensure adjacent vertex radii are computed where applicable
80          if (segIndex > 0 && this.vertexRadii[segIndex] == null)
81          {
82              this.vertexRadii[segIndex] = computeProjectedVertexRadius(segIndex);
83          }
84          if (segIndex < n - 1 && this.vertexRadii[segIndex + 1] == null)
85          {
86              this.vertexRadii[segIndex + 1] = computeProjectedVertexRadius(segIndex + 1);
87          }
88  
89          if (segIndex == 0)
90          {
91              // at start, only vertex 1 exists as internal
92              return Optional.ofNullable(n >= 2 ? this.vertexRadii[1] : null);
93          }
94          if (segIndex == n - 1)
95          {
96              // at end, only vertex n-1 exists as internal
97              return Optional.of(this.vertexRadii[n - 1]);
98          }
99  
100         final Length left = this.vertexRadii[segIndex];
101         final Length right = this.vertexRadii[segIndex + 1];
102         if (left == null && right == null)
103         {
104             return Optional.empty();
105         }
106         else if (left == null)
107         {
108             return Optional.of(right);
109         }
110         else if (right == null)
111         {
112             return Optional.of(left);
113         }
114         return Optional.of(Math.abs(left.si) <= Math.abs(right.si) ? left : right);
115     }
116 
117     /**
118      * Returns the directional radius at an internal vertex (index in [1 .. size() - 2]). If the geometry is degenerate or
119      * helper cannot construct a valid intersection, returns NaN.
120      * @param index vertex index
121      * @return signed radius at the vertex (NaN if undefined)
122      * @throws IndexOutOfBoundsException if index not in [1 .. size() - 2]
123      */
124     public synchronized Optional<Length> radiusAtVertex(final int index) throws IndexOutOfBoundsException
125     {
126         Throw.when(index < 1 || index > this.line.size() - 2, IndexOutOfBoundsException.class,
127                 "Index %s is out of bounds [1 .. %s]", index, this.line.size() - 2);
128         if (this.vertexRadii[index] == null)
129         {
130             this.vertexRadii[index] = computeProjectedVertexRadius(index);
131         }
132         return Optional.ofNullable(this.vertexRadii[index]);
133     }
134 
135     /**
136      * Computes the vertex radius.
137      * @param index index
138      * @return computed radius, {@code null} if there is no radius
139      */
140     private Length computeProjectedVertexRadius(final int index)
141     {
142 
143         // Determine which adjacent edge is shorter
144         final double lenPrev = this.line.lengthAtIndex(index) - this.line.lengthAtIndex(index - 1);
145         final double lenNext = this.line.lengthAtIndex(index + 1) - this.line.lengthAtIndex(index);
146         final int shortIndex = lenPrev <= lenNext ? index : index + 1;
147 
148         // Midpoint of the shorter edge
149         final Point2d aS = this.line.get(shortIndex - 1);
150         final Point2d bS = this.line.get(shortIndex);
151         final Point2d mid = new Point2d(0.5 * (aS.x + bS.x), 0.5 * (aS.y + bS.y));
152 
153         // Perpendicular line through the midpoint: rotate edge vector (ex, ey) by -90 deg -> (ey, -ex). i.e. right
154         final double ex = bS.x - aS.x;
155         final double ey = bS.y - aS.y;
156         final Point2d midPerpEnd = new Point2d(mid.x + ey, mid.y - ex);
157 
158         // Angle-splitting line from the helper at the vertex (null, null directions)
159         final Point2d vertex = this.line.get(index);
160         final FractionalProjectionHelper.Helper h = this.fracHelper.helperAtVertex(index, null, null);
161 
162         final Point2d rayEnd;
163         if (h.hasCenter())
164         {
165             rayEnd = new Point2d(h.cx(), h.cy());
166         }
167         else
168         {
169             // Use direction as provided by the helper
170             rayEnd = new Point2d(vertex.x + h.dx(), vertex.y + h.dy());
171         }
172 
173         // Intersection of the two infinite lines
174         Point2d inter = intersectionOrNull(mid, midPerpEnd, vertex, rayEnd);
175         if (inter == null)
176         {
177             return null;
178         }
179 
180         final double radius = inter.distance(mid);
181         final double i2p2 = inter.distance(midPerpEnd);
182         final double refLen = Math.min(lenPrev, lenNext);
183         final boolean isLeft = (radius < i2p2 && i2p2 > refLen);
184         return Length.ofSI(isLeft ? radius : -radius);
185     }
186 
187     /**
188      * Intersection of infinite lines (p1->p2) and (p3->p4). Returns null if near-parallel.
189      * @param p1 first point of first line
190      * @param p2 second point of first line
191      * @param p3 first point of second line
192      * @param p4 second point of second line
193      * @return intersection
194      */
195     // TODO: can be replaced with djutils version with eps once it is published in djutils
196     private static Point2d intersectionOrNull(final Point2d p1, final Point2d p2, final Point2d p3, final Point2d p4)
197     {
198         final double x1 = p1.x, y1 = p1.y;
199         final double x2 = p2.x, y2 = p2.y;
200         final double x3 = p3.x, y3 = p3.y;
201         final double x4 = p4.x, y4 = p4.y;
202 
203         final double dx1 = x2 - x1, dy1 = y2 - y1;
204         final double dx2 = x4 - x3, dy2 = y4 - y3;
205 
206         final double denom = dx1 * dy2 - dy1 * dx2;
207         if (Math.abs(denom) < INTERSECTION_EPS)
208         {
209             return null; // near-parallel
210         }
211         final double t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom;
212         return new Point2d(x1 + t * dx1, y1 + t * dy1);
213     }
214 
215     /**
216      * Find containing segment index for absolute s in [0 .. totalLen].
217      * @param absS segment at index
218      * @return segment at s
219      */
220     private int segmentIndexAt(final double absS)
221     {
222         final int n = this.line.size();
223         for (int i = 0; i < n - 1; i++)
224         {
225             final double s1 = this.line.lengthAtIndex(i + 1);
226             if (absS <= s1)
227             {
228                 return i;
229             }
230         }
231         return n - 2; // guard at the end
232     }
233 }