View Javadoc
1   package org.opentrafficsim.base.geometry;
2   
3   import java.awt.geom.Line2D;
4   
5   import org.djunits.value.vdouble.scalar.Direction;
6   import org.djutils.draw.line.PolyLine2d;
7   import org.djutils.draw.point.Point2d;
8   import org.djutils.exceptions.Throw;
9   import org.djutils.math.AngleUtil;
10  
11  /**
12   * Fractional projection helper for {@link OtsLine2d}.
13   * <p>
14   * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
15   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
16   * </p>
17   * @author Wouter Schakel
18   * @see OtsLine2d#projectFractionalAt
19   */
20  public final class FractionalProjectionHelper
21  {
22  
23      /** Numerical precision for fractional projection comparisons. */
24      private static final double FRAC_PROJ_PRECISION = 2e-5;
25  
26      /** Epsilon to consider two lines near-parallel in intersection tests. */
27      private static final double INTERSECTION_EPS = 1e-7;
28  
29      /** Owning line (used to avoid duplicating points and cumulative lengths). */
30      private final OtsLine2d line;
31  
32      /** Number of segments. */
33      private final int n;
34  
35      /** Whether fixed helper 1..n-2 is a center, other wise a direction. */
36      private final boolean[] isCenter; // length n; interior entries set, edges ignored
37  
38      /** Helper center x coordinate. */
39      private final double[] centerX; // length n; only valid when isCenter[i] == true
40  
41      /** Helper center y coordinate. */
42      private final double[] centerY; // length n
43  
44      /** Helper direction x component. */
45      private final double[] dirX; // length n; only valid when isCenter[i] == false
46  
47      /** Helper direction y component. */
48      private final double[] dirY; // length n
49  
50      /** Intersection of first two unit-offset segments (start direction-independent). */
51      private Point2d firstOffsetIntersection;
52  
53      /** Intersection of last two unit-offset segments (end direction-independent). */
54      private Point2d lastOffsetIntersection;
55  
56      /**
57       * Cached helpers for {@code null} start and end direction. Often used by {@link OtsLine2d#radiusAtFraction} and
58       * {@link OtsLine2d#radiusAtVertex(int)}.
59       */
60      private EdgeHelpers edgeNullNull; // cached helpers for (null,null)
61  
62      /** Last none-both-{@code null} directions. */
63      private DirectionKey lastKey; // last used (may include null on one side)
64  
65      /** Helpers of last none-both-{@code null} directions. */
66      private EdgeHelpers edgeLast; // cached helpers for lastKey
67  
68      /**
69       * Constructor.
70       * @param line line
71       */
72      FractionalProjectionHelper(final OtsLine2d line)
73      {
74          this.line = line;
75          this.n = this.line.size() - 1;
76  
77          this.isCenter = new boolean[this.n];
78          this.centerX = new double[this.n];
79          this.centerY = new double[this.n];
80          this.dirX = new double[this.n];
81          this.dirY = new double[this.n];
82  
83          // Pre-compute interior (none-edge) helpers and the two direction-independent offset intersections
84          precomputeFixedHelpers();
85      }
86  
87      /**
88       * Pre-computes all fixed helpers.
89       */
90      private void precomputeFixedHelpers()
91      {
92          if (this.n < 2)
93          {
94              // No interior segments; nothing to pre-compute
95              return;
96          }
97  
98          // First two unit-offset segments (direction independent)
99          PolyLine2d prevOfs = unitOffsetSegment(0);
100         PolyLine2d nextOfs = unitOffsetSegment(1);
101 
102         Point2d parStart = intersectionOrFallbackMid(prevOfs.get(0), prevOfs.get(1), nextOfs.get(0), nextOfs.get(1));
103         this.firstOffsetIntersection = parStart;
104 
105         // Special case: exactly two segments -> lastOffsetIntersection equals parStart
106         if (this.n == 2)
107         {
108             this.lastOffsetIntersection = parStart;
109             return; // no interior helpers to compute
110         }
111 
112         // Build for interior segments i = 1 .. n-2
113         for (int i = 1; i <= this.n - 2; i++)
114         {
115             prevOfs = nextOfs;
116             if (i + 1 <= this.n - 1)
117             {
118                 nextOfs = unitOffsetSegment(i + 1);
119             }
120             final Point2d parEnd = intersectionOrFallbackMid(prevOfs.get(0), prevOfs.get(1), nextOfs.get(0), nextOfs.get(1));
121 
122             // Intersection of helper lines: (vertex i -> parStart) and (vertex i+1 -> parEnd)
123             final Point2d c = intersectionOrNull(this.line.get(i), parStart, this.line.get(i + 1), parEnd);
124             if (c != null)
125             {
126                 this.isCenter[i] = true;
127                 this.centerX[i] = c.x;
128                 this.centerY[i] = c.y;
129             }
130             else
131             {
132                 this.isCenter[i] = false;
133                 this.dirX[i] = parStart.x - this.line.get(i).x;
134                 this.dirY[i] = parStart.y - this.line.get(i).y;
135             }
136 
137             parStart = parEnd;
138             if (i == this.n - 2)
139             {
140                 this.lastOffsetIntersection = parStart;
141             }
142         }
143     }
144 
145     /**
146      * Unit-offset line to the LEFT of segment i (distance 1.0).
147      * @param i segment
148      * @return offset line
149      */
150     private PolyLine2d unitOffsetSegment(final int i)
151     {
152         return new PolyLine2d(this.line.get(i), this.line.get(i + 1)).offsetLine(1.0);
153     }
154 
155     /**
156      * Fractionally project a point on the polyline using the fractional helper logic. Falls back via the given strategy when
157      * fractional projection is not applicable.
158      * @param start direction in first point
159      * @param end direction in last point
160      * @param x x-coordinate of point to project
161      * @param y y-coordinate of point to project
162      * @param fallback fallback method for when fractional projection fails
163      * @return fractional position along this line of the fractional projection on that line of a point
164      * @see OtsLine2d#projectFractionalAt
165      */
166     public synchronized double projectFractionalAt(final Direction start, final Direction end, final double x, final double y,
167             final FractionalFallback fallback)
168     {
169         Throw.whenNull(fallback, "fallback");
170 
171         // Determine edge helpers for these directions (2-slot cache: (null,null) + lastKey).
172         final EdgeHelpers edges = getEdgeHelpers(start, end);
173 
174         // Compute distances to segments; consider only those near the minimum distance.
175         double minD = Double.POSITIVE_INFINITY;
176         final double[] segDist = new double[this.n];
177         for (int i = 0; i < this.n; i++)
178         {
179             Point2d a = this.line.get(i);
180             Point2d b = this.line.get(i + 1);
181             segDist[i] = Line2D.ptSegDist(a.x, a.y, b.x, b.y, x, y);
182             if (segDist[i] < minD)
183             {
184                 minD = segDist[i];
185             }
186         }
187 
188         double bestDistance = Double.POSITIVE_INFINITY;
189         int bestSeg = -1;
190         double bestSegFrac = 0.0;
191 
192         final Point2d ext = new Point2d(x, y);
193 
194         for (int i = 0; i < this.n; i++)
195         {
196             if (segDist[i] > minD + FRAC_PROJ_PRECISION)
197             {
198                 continue;
199             }
200             final Helper h = helperForSegment(i, edges);
201             final Point2d p = intersectProjection(i, h, ext);
202             if (p == null)
203             {
204                 continue;
205             }
206 
207             // Ensure intersection lies on segment (within tolerance)
208             Point2d a = this.line.get(i);
209             Point2d b = this.line.get(i + 1);
210             final double segLen = a.distance(b) + FRAC_PROJ_PRECISION;
211             if (p.distance(a) > segLen || p.distance(b) > segLen)
212             {
213                 continue;
214             }
215 
216             // Prefer the nearest intersection to the external point
217             final double dist = p.distance(ext);
218             if (dist < bestDistance)
219             {
220                 bestDistance = dist;
221                 // Compute fraction within segment
222                 final double segActual = a.distance(b);
223                 final double along = a.distance(p);
224                 bestSegFrac = Math.min(1.0, Math.max(0.0, segActual > 0.0 ? (along / segActual) : 0.0));
225                 bestSeg = i;
226             }
227         }
228 
229         if (bestSeg < 0)
230         {
231             // Fractional projection not applicable; fallback
232             return fallback.getFraction(this, x, y);
233         }
234 
235         // Convert segment-local fraction to global fraction using line.lengthAtIndex
236         final double segStartLen = this.line.lengthAtIndex(bestSeg);
237         final double segEndLen = this.line.lengthAtIndex(bestSeg + 1);
238         final double abs = segStartLen + bestSegFrac * (segEndLen - segStartLen);
239         final double total = this.line.lengthAtIndex(this.line.size() - 1);
240         return abs / total;
241     }
242 
243     /**
244      * Get the helper (center or direction) at a vertex index using (start,end) dependent edges. Useful for curvature / radius
245      * logic. Supply ({@code null}, {@code null}) if that is the desired configuration.
246      * @param vertexIndex 0 .. size-1
247      * @param start direction at start (can be {@code null})
248      * @param end direction at end (can be {@code null})
249      * @return helper
250      */
251     public synchronized Helper helperAtVertex(final int vertexIndex, final Direction start, final Direction end)
252     {
253         Throw.when(vertexIndex < 0 || vertexIndex > this.line.size() - 1, IndexOutOfBoundsException.class,
254                 "vertexIndex %s out of bounds [0..%s]", vertexIndex, this.line.size() - 1);
255         if (vertexIndex < 0 || vertexIndex > this.line.size() - 1)
256         {
257             throw new IndexOutOfBoundsException("vertexIndex out of bounds");
258         }
259         final EdgeHelpers edges = getEdgeHelpers(start, end);
260         if (vertexIndex == 0)
261         {
262             return edges.first;
263         }
264         if (vertexIndex >= this.n - 1)
265         {
266             return edges.last;
267         }
268         // Interior vertex i corresponds to segment i (between i and i+1)
269         return helperForInteriorSegment(vertexIndex);
270     }
271 
272     /**
273      * Fallback strategies for when fractional projection is not applicable.
274      */
275     public enum FractionalFallback
276     {
277 
278         /** Orthogonal projection clamped to [0,1]. */
279         ORTHOGONAL
280         {
281             @Override
282             double getFraction(final FractionalProjectionHelper helper, final double x, final double y)
283             {
284                 return helper.line.projectOrthogonalSnapAt(x, y);
285             }
286         },
287 
288         /** Orthogonal projection allowing extension beyond end-points. */
289         ORTHOGONAL_EXTENDED
290         {
291             @Override
292             double getFraction(final FractionalProjectionHelper helper, final double x, final double y)
293             {
294                 return helper.line.projectOrthogonalSnapAt(x, y, false);
295             }
296         },
297 
298         /** Nearest end-point as fraction &lt;0 before start, &gt;1 after end. */
299         ENDPOINT
300         {
301             @Override
302             double getFraction(final FractionalProjectionHelper helper, final double x, final double y)
303             {
304                 final Point2d p = new Point2d(x, y);
305                 final Point2d a = helper.line.get(0);
306                 final Point2d b = helper.line.get(helper.line.size() - 1);
307                 final double dStart = p.distance(a);
308                 final double dEnd = p.distance(b);
309                 final double total = helper.line.lengthAtIndex(helper.line.size() - 1);
310                 return (dStart < dEnd) ? (-dStart / total) : ((total + dEnd) / total);
311             }
312         },
313 
314         /** Return NaN. */
315         NaN
316         {
317             @Override
318             double getFraction(final FractionalProjectionHelper h, final double x, final double y)
319             {
320                 return Double.NaN;
321             }
322         };
323 
324         /**
325          * Returns fraction for when fractional projection fails as the point is beyond the line or from numerical limitations.
326          * @param helper helper
327          * @param x x coordinate of point
328          * @param y y coordinate of point
329          * @return fraction for when fractional projection fails
330          */
331         abstract double getFraction(FractionalProjectionHelper helper, double x, double y);
332     }
333 
334     /**
335      * Value object: per-segment helper, either center or direction.
336      * @param hasCenter whether this is a center helper
337      * @param cx center x coordinate
338      * @param cy center y coordinate
339      * @param dx direction x component
340      * @param dy direction y component
341      */
342     public record Helper(boolean hasCenter, double cx, double cy, double dx, double dy)
343     {
344         /**
345          * Factory for a center-based helper.
346          * @param cx center x coordinate
347          * @param cy center y coordinate
348          * @return center-based helper
349          */
350         public static Helper center(final double cx, final double cy)
351         {
352             return new Helper(true, cx, cy, Double.NaN, Double.NaN);
353         }
354 
355         /**
356          * Factory for a direction-based helper.
357          * @param dx direction x component
358          * @param dy direction y component
359          * @return direction-based helper
360          */
361         public static Helper direction(final double dx, final double dy)
362         {
363             return new Helper(false, Double.NaN, Double.NaN, dx, dy);
364         }
365     }
366 
367     /**
368      * Value object for the two edge helpers.
369      * @param first helper
370      * @param last helper
371      */
372     private record EdgeHelpers(Helper first, Helper last)
373     {
374     }
375 
376     /**
377      * Cache key for directions (normalized angles; nulls allowed).
378      * @param start start direction
379      * @param end end direction
380      */
381     public record DirectionKey(Double start, Double end)
382     {
383         /**
384          * Create key with normalized directions, which may be {@code null}.
385          * @param start start direction
386          * @param end end direction
387          * @return key
388          */
389         public static DirectionKey of(final Direction start, final Direction end)
390         {
391             Double sa = (start == null) ? null : AngleUtil.normalizeAroundZero(start.si);
392             Double ea = (end == null) ? null : AngleUtil.normalizeAroundZero(end.si);
393             return new DirectionKey(sa, ea);
394         }
395     }
396 
397     /**
398      * Returns helper for interior (non-edge) segment.
399      * @param segIndex index
400      * @return helper for interior (non-edge) segment
401      */
402     private Helper helperForInteriorSegment(final int segIndex)
403     {
404         if (this.isCenter[segIndex])
405         {
406             return Helper.center(this.centerX[segIndex], this.centerY[segIndex]);
407         }
408         return Helper.direction(this.dirX[segIndex], this.dirY[segIndex]);
409     }
410 
411     /**
412      * Returns helper for segment.
413      * @param segIndex index
414      * @param edges edge helpers
415      * @return helper for segment
416      */
417     private Helper helperForSegment(final int segIndex, final EdgeHelpers edges)
418     {
419         if (segIndex == 0)
420         {
421             return edges.first;
422         }
423         if (segIndex == this.n - 1)
424         {
425             return edges.last;
426         }
427         return helperForInteriorSegment(segIndex);
428     }
429 
430     /**
431      * Returns edge helpers based on directions.
432      * @param start start direction
433      * @param end end direction
434      * @return edge helpers based on directions
435      */
436     private EdgeHelpers getEdgeHelpers(final Direction start, final Direction end)
437     {
438         if (start == null && end == null)
439         {
440             if (this.edgeNullNull == null)
441             {
442                 this.edgeNullNull = computeEdgeHelpers(null, null);
443             }
444             return this.edgeNullNull;
445         }
446         final DirectionKey key = DirectionKey.of(start, end);
447         if (key.equals(this.lastKey) && this.edgeLast != null)
448         {
449             return this.edgeLast;
450         }
451         this.edgeLast = computeEdgeHelpers(start, end);
452         this.lastKey = key;
453         return this.edgeLast;
454     }
455 
456     /**
457      * Compute edge helpers.
458      * @param start start direction
459      * @param end end direction
460      * @return edge helpers
461      */
462     private EdgeHelpers computeEdgeHelpers(final Direction start, final Direction end)
463     {
464         // Angles (default to segment direction if null)
465         final double startAng = (start == null)
466                 ? Math.atan2(this.line.get(1).y - this.line.get(0).y, this.line.get(1).x - this.line.get(0).x) : start.si;
467         final double endAng = (end == null) ? Math.atan2(this.line.get(this.n).y - this.line.get(this.n - 1).y,
468                 this.line.get(this.n).x - this.line.get(this.n - 1).x) : end.si;
469 
470         // Unit offset points at start and end of line
471         final Point2d p1 = new Point2d(this.line.get(0).x + Math.cos(startAng + Math.PI / 2.0),
472                 this.line.get(0).y + Math.sin(startAng + Math.PI / 2.0));
473         final Point2d p2 = new Point2d(this.line.get(this.n).x + Math.cos(endAng + Math.PI / 2.0),
474                 this.line.get(this.n).y + Math.sin(endAng + Math.PI / 2.0));
475 
476         Helper hFirst, hLast;
477         if (this.n == 1)
478         {
479             // Single segment: edges collapse; center is intersection of offset rays
480             final Point2d c = intersectionOrNull(this.line.get(0), p1, this.line.get(1), p2);
481             if (c != null)
482             {
483                 hFirst = Helper.center(c.x, c.y);
484             }
485             else
486             {
487                 hFirst = Helper.direction(p1.x - this.line.get(0).x, p1.y - this.line.get(0).y);
488             }
489             // Same for last (same segment)
490             hLast = hFirst;
491         }
492         else
493         {
494             // Use direction-independent offset intersections
495             Point2d cFirst = intersectionOrNull(this.line.get(0), p1, this.line.get(1), this.firstOffsetIntersection);
496             if (cFirst != null)
497             {
498                 hFirst = Helper.center(cFirst.x, cFirst.y);
499             }
500             else
501             {
502                 hFirst = Helper.direction(p1.x - this.line.get(0).x, p1.y - this.line.get(0).y);
503             }
504 
505             Point2d cLast =
506                     intersectionOrNull(this.line.get(this.n - 1), this.lastOffsetIntersection, this.line.get(this.n), p2);
507             if (cLast != null)
508             {
509                 hLast = Helper.center(cLast.x, cLast.y);
510             }
511             else
512             {
513                 hLast = Helper.direction(p2.x - this.line.get(this.n).x, p2.y - this.line.get(this.n).y);
514             }
515         }
516         return new EdgeHelpers(hFirst, hLast);
517     }
518 
519     /**
520      * Projects external point to segment.
521      * @param segIndex index
522      * @param helper helper of the segment
523      * @param ext external point
524      * @return projection to segment, or {@code null} if no valid projection
525      */
526     private Point2d intersectProjection(final int segIndex, final Helper helper, final Point2d ext)
527     {
528         final Point2d a = this.line.get(segIndex);
529         final Point2d b = this.line.get(segIndex + 1);
530 
531         if (helper.hasCenter)
532         {
533             // Intersection of (center -> ext) ray with segment line
534             final Point2d c = new Point2d(helper.cx, helper.cy);
535             final Point2d p = intersectionOrNull(c, ext, a, b);
536             if (p == null)
537             {
538                 return null;
539             }
540             // Ensure center is not between ext and intersection:
541             final double v1x = p.x - c.x, v1y = p.y - c.y;
542             final double v2x = ext.x - c.x, v2y = ext.y - c.y;
543             final double dot = v1x * v2x + v1y * v2y;
544             if (dot <= FRAC_PROJ_PRECISION)
545             {
546                 return null;
547             }
548             return p;
549         }
550         // Parallel helper lines: project along stored direction from ext
551         final Point2d off = new Point2d(ext.x + helper.dx, ext.y + helper.dy);
552         return intersectionOrNull(ext, off, a, b);
553     }
554 
555     /**
556      * Intersection of infinite lines (p1->p2) and (p3->p4). Returns null if near-parallel.
557      * @param p1 first point of first line
558      * @param p2 second point of first line
559      * @param p3 first point of second line
560      * @param p4 second point of second line
561      * @return intersection
562      */
563     // TODO: can be replaced with djutils version with eps once it is published in djutils
564     private static Point2d intersectionOrNull(final Point2d p1, final Point2d p2, final Point2d p3, final Point2d p4)
565     {
566         final double x1 = p1.x, y1 = p1.y;
567         final double x2 = p2.x, y2 = p2.y;
568         final double x3 = p3.x, y3 = p3.y;
569         final double x4 = p4.x, y4 = p4.y;
570 
571         final double dx1 = x2 - x1, dy1 = y2 - y1;
572         final double dx2 = x4 - x3, dy2 = y4 - y3;
573 
574         final double denom = dx1 * dy2 - dy1 * dx2;
575         if (Math.abs(denom) < INTERSECTION_EPS)
576         {
577             return null; // near-parallel
578         }
579         final double t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom;
580         return new Point2d(x1 + t * dx1, y1 + t * dy1);
581     }
582 
583     /**
584      * Returns intersection for parallel segments, or midpoint fallback when intersection is null or unstable.
585      * @param a1 first point of first parallel segment
586      * @param a2 second point of first parallel segment
587      * @param b1 first point of second parallel segment
588      * @param b2 second point of second parallel segment
589      * @return intersection
590      */
591     private static Point2d intersectionOrFallbackMid(final Point2d a1, final Point2d a2, final Point2d b1, final Point2d b2)
592     {
593         final Point2d inter = intersectionOrNull(a1, a2, b1, b2);
594         if (inter == null)
595         {
596             return midpoint(a2, b1);
597         }
598         final double dStraight = a2.distance(b1);
599         if (dStraight < Math.min(a2.distance(inter), b1.distance(inter)))
600         {
601             return midpoint(a2, b1);
602         }
603         return inter;
604     }
605 
606     /**
607      * Computes the mid-point.
608      * @param p first point
609      * @param q second point
610      * @return mid-point
611      */
612     private static Point2d midpoint(final Point2d p, final Point2d q)
613     {
614         return new Point2d(0.5 * (p.x + q.x), 0.5 * (p.y + q.y));
615     }
616 
617 }