// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Core.DataStructure { using System; using System.Text.Json.Serialization; using Helper; using Math; using UnityEngine; /// /// Axis-aligned bounding box that uses half-open semantics on its upper bounds so hit tests behave consistently with grid and voxel data. /// Useful for spatial queries, containment checks, and broad-phase culling without off-by-one float errors. /// /// /// /// public readonly struct BoundingBox3D : IEquatable { private const float MinimumExclusivePadding = 1e-6f; public readonly Vector3 min; public readonly Vector3 max; [JsonConstructor] public BoundingBox3D(Vector3 min, Vector3 max) { if (min.x > max.x || min.y > max.y || min.z > max.z) { throw new ArgumentException("Min must be less than or equal to max on all axes."); } this.min = min; // Only ensure exclusive max if we have degenerate bounds (min == max) if (min.x == max.x || min.y == max.y || min.z == max.z) { this.max = EnsureExclusiveMax(min, max); } else { this.max = max; } } public Vector3 Center => (min + max) * 0.5f; public Vector3 Size => max - min; public float Volume { get { Vector3 size = Size; return size.x * size.y * size.z; } } public bool IsEmpty => max.x <= min.x || max.y <= min.y || max.z <= min.z; public static BoundingBox3D Empty => default; public static BoundingBox3D FromCenterAndSize(Vector3 center, Vector3 size) { Vector3 half = size * 0.5f; Vector3 min = center - half; Vector3 max = center + half; return new BoundingBox3D(min, max); } public static BoundingBox3D FromClosedBounds(Bounds bounds) { return new BoundingBox3D(bounds.min, bounds.max); } /// /// Creates a BoundingBox3D from a Unity Bounds treating the Bounds' max as inclusive /// by converting the closed interval [min, max] to a half-open interval [min, max) /// with an exclusive max that is the next representable float past the provided max. /// This makes point-in-box tests consistent with Unity's Bounds.Contains semantics. /// public static BoundingBox3D FromClosedBoundsInclusiveMax(Bounds bounds) { Vector3 min = bounds.min; Vector3 max = bounds.max; // Convert Unity's closed max to half-open by nudging max strictly past the provided value Vector3 exclusiveMax = new(NextFloat(max.x), NextFloat(max.y), NextFloat(max.z)); return new BoundingBox3D(min, exclusiveMax); } public static BoundingBox3D FromPoint(Vector3 point) { Vector3 exclusiveMax = new(NextFloat(point.x), NextFloat(point.y), NextFloat(point.z)); return new BoundingBox3D(point, exclusiveMax); } public BoundingBox3D ExpandToInclude(Vector3 point) { Vector3 localMin = min; if (point.x < localMin.x) { localMin.x = point.x; } if (point.y < localMin.y) { localMin.y = point.y; } if (point.z < localMin.z) { localMin.z = point.z; } Vector3 localMax = max; if (point.x >= localMax.x) { localMax.x = NextFloat(point.x); } if (point.y >= localMax.y) { localMax.y = NextFloat(point.y); } if (point.z >= localMax.z) { localMax.z = NextFloat(point.z); } // Skip validation since we know the bounds are valid return new BoundingBox3D(localMin, localMax); } public BoundingBox3D ExpandToInclude(BoundingBox3D other) { if (other.IsEmpty) { return this; } if (IsEmpty) { return other; } Vector3 localMin = new( Math.Min(min.x, other.min.x), Math.Min(min.y, other.min.y), Math.Min(min.z, other.min.z) ); Vector3 localMax = new( Math.Max(max.x, other.max.x), Math.Max(max.y, other.max.y), Math.Max(max.z, other.max.z) ); // Skip validation since we know the bounds are valid return new BoundingBox3D(localMin, localMax); } public BoundingBox3D Encapsulate(Vector3 point) => ExpandToInclude(point); public BoundingBox3D Encapsulate(BoundingBox3D other) => ExpandToInclude(other); public BoundingBox3D Union(BoundingBox3D other) => ExpandToInclude(other); public BoundingBox3D EnsureMinimumSize(float minimum) { if (minimum <= 0f || IsEmpty) { return this; } Vector3 size = Size; Vector3 localMin = min; Vector3 localMax = max; bool changed = false; if (size.x < minimum) { float delta = (minimum - size.x) * 0.5f; localMin.x -= delta; localMax.x += delta; changed = true; } if (size.y < minimum) { float delta = (minimum - size.y) * 0.5f; localMin.y -= delta; localMax.y += delta; changed = true; } if (size.z < minimum) { float delta = (minimum - size.z) * 0.5f; localMin.z -= delta; localMax.z += delta; changed = true; } return changed ? new BoundingBox3D(localMin, localMax) : this; } public bool Contains(Vector3 point) { return point.x >= min.x && point.y >= min.y && point.z >= min.z && point.x < max.x && point.y < max.y && point.z < max.z; } public bool Contains(BoundingBox3D other) { // Empty boxes are not contained by anything, not even themselves if (other.IsEmpty) { return false; } return min.x <= other.min.x && min.y <= other.min.y && min.z <= other.min.z && max.x >= other.max.x && max.y >= other.max.y && max.z >= other.max.z; } public BoundingBox3D? Intersection(BoundingBox3D other) { if (!Intersects(other)) { return null; } Vector3 intersectionMin = new( Math.Max(min.x, other.min.x), Math.Max(min.y, other.min.y), Math.Max(min.z, other.min.z) ); Vector3 intersectionMax = new( Math.Min(max.x, other.max.x), Math.Min(max.y, other.max.y), Math.Min(max.z, other.max.z) ); // Skip validation since we know it intersects return new BoundingBox3D(intersectionMin, intersectionMax); } public bool Intersects(BoundingBox3D other) { return min.x < other.max.x && max.x > other.min.x && min.y < other.max.y && max.y > other.min.y && min.z < other.max.z && max.z > other.min.z; } /// /// Determines whether this bounding box intersects with a line segment. /// Returns true if the line segment intersects the bounding box. /// /// The line segment to test for intersection. /// True if the line segment intersects the bounding box. public bool Intersects(Line3D line) { return line.Intersects(this); } /// /// Calculates the shortest distance from this bounding box to a line segment. /// Returns 0 if the line intersects the bounding box. /// /// The line segment to measure distance from. /// The shortest distance from the bounding box to the line segment. public float DistanceToLine(Line3D line) { return line.DistanceToBounds(this); } /// /// Finds the closest point on a line segment to this bounding box. /// /// The line segment. /// The closest point on the line segment to this bounding box. public Vector3 ClosestPointOnLine(Line3D line) { return line.ClosestPointOnBounds(this); } public Vector3 ClosestPoint(Vector3 point) { // For half-open semantics [min, max), the valid range is [min, max) // But for closest point purposes, we clamp to the representable boundary return new Vector3( Mathf.Clamp(point.x, min.x, max.x), Mathf.Clamp(point.y, min.y, max.y), Mathf.Clamp(point.z, min.z, max.z) ); } public void GetCorners(Vector3[] corners) { if (corners == null || corners.Length < 8) { throw new ArgumentException( "Corners array must not be null and have at least 8 elements." ); } corners[0] = new Vector3(min.x, min.y, min.z); corners[1] = new Vector3(max.x, min.y, min.z); corners[2] = new Vector3(min.x, max.y, min.z); corners[3] = new Vector3(max.x, max.y, min.z); corners[4] = new Vector3(min.x, min.y, max.z); corners[5] = new Vector3(max.x, min.y, max.z); corners[6] = new Vector3(min.x, max.y, max.z); corners[7] = new Vector3(max.x, max.y, max.z); } public float DistanceSquaredTo(Vector3 point) { Vector3 closest = ClosestPoint(point); return (closest - point).sqrMagnitude; } public Bounds ToBounds() { Vector3 size = Size; return new Bounds(Center, size); } public bool Equals(BoundingBox3D other) { return min.Equals(other.min) && max.Equals(other.max); } public override bool Equals(object obj) { return obj is BoundingBox3D other && Equals(other); } public override int GetHashCode() { return Objects.HashCode(min, max); } public static bool operator ==(BoundingBox3D left, BoundingBox3D right) { return left.Equals(right); } public static bool operator !=(BoundingBox3D left, BoundingBox3D right) { return !left.Equals(right); } public override string ToString() { return $"BoundingBox3D(min: {min}, max: {max})"; } private static Vector3 EnsureExclusiveMax(Vector3 min, Vector3 max) { Vector3 exclusive = max; if (exclusive.x <= min.x) { exclusive.x = NextFloat(min.x + MinimumExclusivePadding); } if (exclusive.y <= min.y) { exclusive.y = NextFloat(min.y + MinimumExclusivePadding); } if (exclusive.z <= min.z) { exclusive.z = NextFloat(min.z + MinimumExclusivePadding); } return exclusive; } private static float NextFloat(float value) { if (float.IsNaN(value) || float.IsInfinity(value)) { return value; } if (value == float.MaxValue) { return value; } if (value == float.MinValue) { return BitConverter.Int32BitsToSingle(unchecked((int)0xFF7FFFFF)); } if (value == 0f) { return float.Epsilon; } int bits = BitConverter.SingleToInt32Bits(value); bits = value > 0f ? bits + 1 : bits - 1; return BitConverter.Int32BitsToSingle(bits); } } }