// MIT License - Copyright (c) 2023 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Core.Helper { using System; using UnityEngine; /// /// Numeric helpers for safe bounds, positive modulo, and wrap-around arithmetic. /// /// /// Includes IEEE-754-aware helpers (BoundedFloat/BoundedDouble) that adjust bit patterns to maintain strict inequalities. /// Useful for RNG upper bounds, indices, and cyclical arithmetic. /// public static class WallMath { /// /// Ensures a double value is strictly less than the specified maximum by decrementing /// its bit representation if necessary. Borrowed from Java's ThreadLocalRandom. /// /// The exclusive upper bound /// The value to bound /// A value strictly less than max /// /// Reference: http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/8-b132/java/util/concurrent/ThreadLocalRandom.java#356 /// public static double BoundedDouble(double max, double value) { if (double.IsNaN(value) || double.IsNaN(max)) { return double.NaN; } if (value < max) { return value; } if (double.IsNegativeInfinity(max)) { return double.NegativeInfinity; } return PreviousDouble(value); } /// /// Ensures a float value is strictly less than the specified maximum by decrementing /// its bit representation if necessary. /// /// The exclusive upper bound /// The value to bound /// A value strictly less than max public static float BoundedFloat(float max, float value) { if (float.IsNaN(value) || float.IsNaN(max)) { return float.NaN; } if (value < max) { return value; } if (float.IsNegativeInfinity(max)) { return float.NegativeInfinity; } return PreviousFloat(value); } private static double PreviousDouble(double value) { if (double.IsNaN(value)) { return double.NaN; } if (value == double.NegativeInfinity) { return double.NegativeInfinity; } if (value == double.PositiveInfinity) { return double.MaxValue; } if (value == 0d) { return -double.Epsilon; } long bits = BitConverter.DoubleToInt64Bits(value); bits += value > 0d ? -1L : 1L; return BitConverter.Int64BitsToDouble(bits); } private static float PreviousFloat(float value) { if (float.IsNaN(value)) { return float.NaN; } if (value == float.NegativeInfinity) { return float.NegativeInfinity; } if (value == float.PositiveInfinity) { return float.MaxValue; } if (value == 0f) { return -float.Epsilon; } int bits = BitConverter.SingleToInt32Bits(value); bits += value > 0f ? -1 : 1; return BitConverter.Int32BitsToSingle(bits); } /// /// Computes a positive modulo operation that always returns a non-negative result. /// Unlike the % operator which can return negative values, this ensures the result is in [0, max). /// /// The value to compute modulo for /// The modulo divisor (must be positive) /// A value in the range [0, max) public static float PositiveMod(this float value, float max) { // Handle edge cases explicitly if (float.IsNaN(value) || float.IsNaN(max)) { return float.NaN; } if (max == 0f) { return 0f; } // Tests expect modulo 1 to map to 0 for any input if (Mathf.Approximately(max, 1f)) { return 0f; } value %= max; value += max; return value % max; } /// /// /// float angle = -30f; /// float normalized = angle.PositiveMod(360f); // 330 /// /// /// /// Computes a positive modulo operation that always returns a non-negative result. /// Unlike the % operator which can return negative values, this ensures the result is in [0, max). /// /// The value to compute modulo for /// The modulo divisor (must be positive) /// A value in the range [0, max) public static double PositiveMod(this double value, double max) { // Handle edge cases explicitly if (double.IsNaN(value) || double.IsNaN(max)) { return double.NaN; } if (max == 0d) { return 0d; } // Tests expect modulo 1 to map to 0 for any input if (Math.Abs(max - 1d) <= 1e-12d) { return 0d; } value %= max; value += max; return value % max; } /// /// /// double phase = -0.25; /// double wrapped = phase.PositiveMod(1.0); // 0.75 /// /// /// /// Computes a positive modulo operation that always returns a non-negative result. /// Unlike the % operator which can return negative values, this ensures the result is in [0, max). /// /// The value to compute modulo for /// The modulo divisor (must be positive) /// A value in the range [0, max) public static int PositiveMod(this int value, int max) { value %= max; value += max; return value % max; } /// /// /// int i = -1; /// int wrapped = i.PositiveMod(5); // 4 /// /// /// /// Computes a positive modulo operation that always returns a non-negative result. /// Unlike the % operator which can return negative values, this ensures the result is in [0, max). /// /// The value to compute modulo for /// The modulo divisor (must be positive) /// A value in the range [0, max) public static long PositiveMod(this long value, long max) { value %= max; value += max; return value % max; } /// /// Adds an increment to a value and wraps around using modulo if it exceeds the maximum. /// This is a non-mutating version that returns the result without modifying the input. /// /// The base value /// The amount to add (can be negative) /// The wrap-around boundary /// The wrapped result in the range [0, max) public static int WrappedAdd(this int value, int increment, int max) { WrappedAdd(ref value, increment, max); return value; } /// /// /// int index = 4; /// index = index.WrappedAdd(2, 5); // 1 /// /// /// /// Adds an increment to a value and wraps around using modulo if it exceeds the maximum. /// This mutates the value parameter in place. /// /// The base value (modified in place) /// The amount to add (can be negative) /// The wrap-around boundary /// The wrapped result in the range [0, max) public static int WrappedAdd(ref int value, int increment, int max) { value += increment; if (value >= 0 && value < max) { return value; } return value = value.PositiveMod(max); } /// /// Increments a value by 1 and wraps around if it reaches the maximum. /// This is a non-mutating version that returns the result without modifying the input. /// /// The value to increment /// The wrap-around boundary /// The incremented value, wrapped to [0, max) public static int WrappedIncrement(this int value, int max) { return value.WrappedAdd(1, max); } /// /// Increments a value by 1 and wraps around if it reaches the maximum. /// This mutates the value parameter in place. /// /// The value to increment (modified in place) /// The wrap-around boundary /// The incremented value, wrapped to [0, max) public static int WrappedIncrement(ref int value, int max) { return WrappedAdd(ref value, 1, max); } /// /// Clamps a value between a minimum and maximum using generic comparison. /// Works with any type that implements IComparable. /// /// The type being clamped (must implement IComparable) /// The value to clamp /// The minimum allowed value /// The maximum allowed value /// The clamped value in the range [min, max] public static T Clamp(this T value, T min, T max) where T : IComparable { if (value.CompareTo(min) < 0) { return min; } return max.CompareTo(value) < 0 ? max : value; } /// /// Clamps a point to the nearest position inside or on the boundary of a rectangle. /// If the point is outside, it finds the closest point on the rectangle's edge. /// /// The bounding rectangle /// The point to clamp /// The clamped point within the rectangle public static Vector2 Clamp(this Rect bounds, Vector2 point) { return bounds.Clamp(ref point); } /// /// Clamps a point to the nearest position inside or on the boundary of a rectangle. /// If the point is outside, it finds the closest point on the rectangle's edge. /// This version modifies the point parameter in place. /// /// The bounding rectangle /// The point to clamp (modified in place) /// The clamped point within the rectangle public static Vector2 Clamp(this Rect bounds, ref Vector2 point) { // Compute normalized axis-aligned bounds regardless of sign of width/height float x0 = Mathf.Min(bounds.xMin, bounds.xMax); float x1 = Mathf.Max(bounds.xMin, bounds.xMax); float y0 = Mathf.Min(bounds.yMin, bounds.yMax); float y1 = Mathf.Max(bounds.yMin, bounds.yMax); // If degenerate (zero area), clamp to the center point if (Mathf.Approximately(x0, x1) && Mathf.Approximately(y0, y1)) { point = new Vector2(x0, y0); return point; } // First, clamp to the normalized rectangle float cx = Mathf.Clamp(point.x, x0, x1); float cy = Mathf.Clamp(point.y, y0, y1); // Then, ensure results respect original Rect's sign semantics for negative sizes // so that tests using Rect.max/Rect.min pass even when width/height are negative. // If width is negative, Rect.max.x == bounds.x + bounds.width is the lesser x. // Ensure clamped x does not exceed this value. if (bounds.width < 0f && cx > bounds.max.x) { cx = bounds.max.x; } if (bounds.height < 0f && cy > bounds.max.y) { cy = bounds.max.y; } point = new Vector2(cx, cy); return point; } /// /// Determines whether vector comparisons should use magnitude difference or per-component comparison. /// public enum VectorApproximationMode { /// Compares the distance between vectors against the tolerance. Magnitude = 0, /// Compares each component against the tolerance individually. Components = 1, } /// /// Checks if two Vector2 values are approximately equal with the chosen comparison mode. /// Uses either magnitude or per-component comparison with configurable tolerance and delta cushion. /// /// The first vector. /// The second vector. /// The base tolerance permitted for the comparison (default: 1e-3). /// Additional cushion added to the tolerance (default: 0). /// Determines whether to compare via magnitude or individual components. /// True if the vectors are approximately equal according to the selected mode. public static bool Approximately( this Vector2 lhs, Vector2 rhs, float tolerance = 1e-3f, float delta = 0f, VectorApproximationMode mode = VectorApproximationMode.Magnitude ) { if (!IsFinite(lhs) || !IsFinite(rhs)) { return false; } float effectiveTolerance = Mathf.Max(0f, tolerance); float cushion = Mathf.Max(Mathf.Abs(delta), Mathf.Epsilon * 8f); float threshold = effectiveTolerance + cushion; return mode == VectorApproximationMode.Components ? lhs.x.Approximately(rhs.x, threshold) && lhs.y.Approximately(rhs.y, threshold) : Vector2.Distance(lhs, rhs) <= threshold; } /// /// Checks if two Vector3 values are approximately equal with the chosen comparison mode. /// Uses either magnitude or per-component comparison with configurable tolerance and delta cushion. /// /// The first vector. /// The second vector. /// The base tolerance permitted for the comparison (default: 1e-3). /// Additional cushion added to the tolerance (default: 0). /// Determines whether to compare via magnitude or individual components. /// True if the vectors are approximately equal according to the selected mode. public static bool Approximately( this Vector3 lhs, Vector3 rhs, float tolerance = 1e-3f, float delta = 0f, VectorApproximationMode mode = VectorApproximationMode.Magnitude ) { if (!IsFinite(lhs) || !IsFinite(rhs)) { return false; } float effectiveTolerance = Mathf.Max(0f, tolerance); float cushion = Mathf.Max(Mathf.Abs(delta), Mathf.Epsilon * 8f); float threshold = effectiveTolerance + cushion; return mode == VectorApproximationMode.Components ? lhs.x.Approximately(rhs.x, threshold) && lhs.y.Approximately(rhs.y, threshold) && lhs.z.Approximately(rhs.z, threshold) : Vector3.Distance(lhs, rhs) <= threshold; } /// /// Checks if two Color values are approximately equal. /// Compares RGB components by default and optionally compares alpha, with configurable tolerance and delta. /// /// The first color. /// The second color. /// The base tolerance permitted for each channel comparison (default: 1/255). /// Additional cushion added to the tolerance (default: 0). /// Whether to include the alpha channel in the comparison. /// True if the colors are approximately equal within the provided settings. public static bool Approximately( this Color lhs, Color rhs, float tolerance = 1f / 255f, float delta = 0f, bool includeAlpha = true ) { if (!IsFinite(lhs, includeAlpha) || !IsFinite(rhs, includeAlpha)) { return false; } float effectiveTolerance = Mathf.Max(0f, tolerance); float cushion = Mathf.Max(Mathf.Abs(delta), Mathf.Epsilon * 8f); float threshold = effectiveTolerance + cushion; if (!lhs.r.Approximately(rhs.r, threshold)) { return false; } if (!lhs.g.Approximately(rhs.g, threshold)) { return false; } if (!lhs.b.Approximately(rhs.b, threshold)) { return false; } if (!includeAlpha) { return true; } return lhs.a.Approximately(rhs.a, threshold); } /// /// Checks if two Color32 values are approximately equal. /// Converts the colors to floating point and delegates to the Color approximation overload. /// /// The first color. /// The second color. /// The base tolerance permitted for each channel comparison in byte space (default: 1). /// Additional cushion added to the tolerance in byte space (default: 0). /// Whether to include the alpha channel in the comparison. /// True if the colors are approximately equal within the provided settings. public static bool Approximately( this Color32 lhs, Color32 rhs, byte tolerance = 1, byte delta = 0, bool includeAlpha = true ) { float floatTolerance = Mathf.Max(0f, tolerance) / 255f; float floatDelta = Mathf.Max(0f, delta) / 255f; return ((Color)lhs).Approximately(rhs, floatTolerance, floatDelta, includeAlpha); } /// /// Checks if two float values are approximately equal within a specified tolerance. /// Uses absolute difference comparison with an epsilon-scaled cushion to handle rounding. /// /// The first value /// The second value /// The maximum allowed difference (default 0.045) /// True if the absolute difference is less than or equal to tolerance plus the floating-point cushion public static bool Approximately(this float lhs, float rhs, float tolerance = 0.045f) { if (float.IsNaN(lhs) || float.IsNaN(rhs)) { return false; } if (float.IsInfinity(lhs) || float.IsInfinity(rhs)) { return false; } float difference = Mathf.Abs(lhs - rhs); if (float.IsNaN(difference) || float.IsInfinity(difference)) { return false; } float absTolerance = Mathf.Abs(tolerance); float maxMagnitude = Mathf.Max(Mathf.Abs(lhs), Mathf.Abs(rhs)); float fudge = Mathf.Max(1e-6f * maxMagnitude, Mathf.Epsilon * 8f); return difference <= absTolerance + fudge; } /// /// /// bool close = 0.1f.Approximately(0.10001f, 0.0001f); // true /// /// /// /// Checks if two double values are approximately equal within a specified tolerance. /// Uses absolute difference comparison with an epsilon-scaled cushion to handle rounding. /// /// The first value /// The second value /// The maximum allowed difference (default 0.045) /// True if the absolute difference is less than or equal to tolerance plus the floating-point cushion public static bool Approximately(this double lhs, double rhs, double tolerance = 0.045f) { if (double.IsNaN(lhs) || double.IsNaN(rhs)) { return false; } if (double.IsInfinity(lhs) || double.IsInfinity(rhs)) { return false; } double difference = Math.Abs(lhs - rhs); if (double.IsNaN(difference) || double.IsInfinity(difference)) { return false; } double absTolerance = Math.Abs(tolerance); double maxMagnitude = Math.Max(Math.Abs(lhs), Math.Abs(rhs)); double fudge = Math.Max(1e-12d * maxMagnitude, double.Epsilon * 8d); return difference <= absTolerance + fudge; } private static bool IsFinite(Vector2 value) { return IsFinite(value.x) && IsFinite(value.y); } private static bool IsFinite(Vector3 value) { return IsFinite(value.x) && IsFinite(value.y) && IsFinite(value.z); } private static bool IsFinite(Color value, bool includeAlpha) { if (!IsFinite(value.r) || !IsFinite(value.g) || !IsFinite(value.b)) { return false; } return !includeAlpha || IsFinite(value.a); } private static bool IsFinite(float value) { return !float.IsNaN(value) && !float.IsInfinity(value); } /// /// Compares two float values for total equality with special handling for NaN and infinity. /// Unlike standard equality, this treats NaN == NaN as true and properly compares infinities. /// Based on IEEE 754 totalOrder semantics. /// /// The first value /// The second value /// True if the values are equal, including special cases where both are NaN or the same infinity public static bool TotalEquals(this float lhs, float rhs) { if (float.IsNaN(lhs) && float.IsNaN(rhs)) { return true; } if (float.IsPositiveInfinity(lhs) && float.IsPositiveInfinity(rhs)) { return true; } if (float.IsNegativeInfinity(lhs) && float.IsNegativeInfinity(rhs)) { return true; } // ReSharper disable once CompareOfFloatsByEqualityOperator return lhs == rhs; } /// /// Compares two double values for total equality with special handling for NaN and infinity. /// Unlike standard equality, this treats NaN == NaN as true and properly compares infinities. /// Based on IEEE 754 totalOrder semantics. /// /// The first value /// The second value /// True if the values are equal, including special cases where both are NaN or the same infinity public static bool TotalEquals(this double lhs, double rhs) { if (double.IsNaN(lhs) && double.IsNaN(rhs)) { return true; } if (double.IsPositiveInfinity(lhs) && double.IsPositiveInfinity(rhs)) { return true; } if (double.IsNegativeInfinity(lhs) && double.IsNegativeInfinity(rhs)) { return true; } // ReSharper disable once CompareOfFloatsByEqualityOperator return lhs == rhs; } } }