# acmenra_cv/instance/obb.py

import math
import logging
import numpy as np
from typing import List, Iterator, Union, Optional

from acmenra_cv.instance.point import Point


logger = logging.getLogger(__name__)


class Obb:
    """
    Represents a 3D Oriented Bounding Box (OBB) defined by canonical geometric parameters.

    Uses explicit property-based state management (matching Box/Polygon contracts) with strict
    validation on assignment. Supports full 3D spatial representation while maintaining 2D
    compatibility for computer vision pipelines.

    Canonical parameters:
    - Position: x, y, z (center coordinates in normalized space)
    - Dimensions: width, height, depth (must be >= 0)
    - Orientation: roll (pitch around X), angle (yaw around Z)

    Key design decisions (contracts):
    - Internal storage: Canonical (x, y, z, w, h, d, r, a). Guarantees geometric validity.
    - Points: Computed lazily from parameters. Always exactly 4 corners (XY projection + Z).
    - left/right/top/bottom: Axis-Aligned Bounding Box (AABB) bounds derived from corners.
    - scale/translate: Transform canonical parameters, invalidate cache, preserve validity.

    All coordinates are maintained in normalized space (0.0-1.0) relative to the
    original image dimensions, making instances resolution-independent.
    """

    def __init__(self,
                 x: float = 0.0, y: float = 0.0, z: float = 0.0,
                 width: float = 0.0, height: float = 0.0, depth: float = 0.0,
                 yaw: float = 0.0, pitch: float = 0.0, roll: float = 0.0) -> None: # tested (good)
        """Initializes a 3D OBB via validated property setters."""
        self.x = x
        self.y = y
        self.z = z
        self.width = width
        self.height = height
        self.depth = depth
        self.yaw = yaw
        self.pitch = pitch
        self.roll = roll

    def __eq__(self, other: object) -> bool: # tested (good)
        """
        Checks if two OBBs are geometrically equivalent.

        Two OBBs are considered equal if all canonical parameters (center, dimensions, angles)
        match within floating-point tolerance.

        Args:
            other (object): Other object to compare.

        Returns:
            bool: True if OBBs have identical canonical state.

        Note:
            - Uses math.isclose() for robust float comparison (rel_tol=1e-9, abs_tol=1e-7)
            - Compares internal canonical parameters, NOT derived points (O(1) performance)
            - Does NOT account for:
              * Different vertex ordering or starting corners
              * Angle periodicity (e.g., 45° vs 225° with swapped width/height)
              * Roll/angle combinations that result in identical 3D orientation
            - For strict geometric equivalence with rotation invariance, use custom logic
        """
        if not isinstance(other, Obb):
            return False

        # Tolerance configuration
        coord_tol = dict(rel_tol=1e-9, abs_tol=1e-7)
        angle_tol = dict(rel_tol=1e-9, abs_tol=1e-3)  # Slightly higher tolerance for angular drift

        return (math.isclose(self._x, other.x, **coord_tol) and
                math.isclose(self._y, other.y, **coord_tol) and
                math.isclose(self._z, other.z, **coord_tol) and
                math.isclose(self._width, other.width, **coord_tol) and
                math.isclose(self._height, other.height, **coord_tol) and
                math.isclose(self._depth, other.depth, **coord_tol) and
                math.isclose(self._yaw, other.yaw, **angle_tol) and
                math.isclose(self._pitch, other.pitch, **angle_tol) and
                math.isclose(self._roll, other.roll, **angle_tol))

    def __repr__(self) -> str: # tested (good)
        """
        Returns a detailed string representation suitable for debugging and reproduction.

        Returns:
            str: Exact representation including all canonical parameters (X, Y, Z, dimensions, angles).
                 Uses repr-safe formatting (!r) for unambiguous parsing and precision preservation.

        Note:
            - Output can theoretically be used to recreate the object in interactive sessions
            - Includes full 3D orientation (yaw, pitch, roll) for state traceability
            - Matches Point/Box repr conventions for consistent pipeline logging
        """
        return (f"Obb(x={self.x!r}, y={self.y!r}, z={self.z!r}, "
                f"width={self.width!r}, height={self.height!r}, depth={self.depth!r}, "
                f"yaw={self.yaw!r}, pitch={self.pitch!r}, roll={self.roll!r})")

    def __str__(self) -> str: # tested (good)
        """
        Returns a human-readable string representation of the OBB.

        Returns:
            str: Concise summary with center coordinates, dimensions, and rotation angle.
                 Formatted with fixed-point precision for readability in logs and UIs.

        Note:
            - Optimized for console logging, operator dashboards, and alert systems
            - Center coordinates and dimensions use 3 decimal places for sub-pixel clarity
            - Angle (Yaw) uses 1 decimal place for immediate spatial orientation awareness
            - Depth is included to maintain 3D consistency without cluttering 2D views
        """
        return (f"Obb(center=({self.x:.3f}, {self.y:.3f}, {self.z:.3f}), "
                f"width={self.width:.3f}, height={self.height:.3f}, depth={self.depth!r}, "
                f"yaw={self.yaw:.1f}°, pitch={self.pitch:.1f}°, roll={self.roll:.1f}°)")

    @property
    def x(self) -> float: # tested (good)
        """
        float: Gets the center X coordinate of the OBB.

        Returns:
            float: X coordinate in normalized range [0.0, 1.0].

        Note:
            - X represents horizontal position (0=left, 1=right)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._x

    @x.setter
    def x(self, x: float) -> None: # tested (good)
        """
        Sets the center X coordinate of the OBB.

        Args:
            x (float): The X coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If x is not of type float.
            ValueError: If x is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(x, float):
            raise TypeError(f"'X' must be '<float>', but got {type(x).__name__}")
        if not 0.0 <= x <= 1.0:
            raise ValueError(f"'X' must be in [0.0, 1.0], but got {x}")
        self._x = x

    @property
    def y(self) -> float: # tested (good)
        """
        float: Gets the center Y coordinate of the OBB.

        Returns:
            float: Y coordinate in normalized range [0.0, 1.0].

        Note:
            - Y represents vertical position (0=top, 1=bottom in image coordinates)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._y

    @y.setter
    def y(self, y: float) -> None: # tested (good)
        """
        Sets the center Y coordinate of the OBB.

        Args:
            y (float): The Y coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If y is not of type float.
            ValueError: If y is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(y, float):
            raise TypeError(f"'Y' must be '<float>', but got {type(y).__name__}")
        if not 0.0 <= y <= 1.0:
            raise ValueError(f"'Y' must be in [0.0, 1.0], but got {y}")
        self._y = y

    @property
    def z(self) -> float: # tested (good)
        """
        float: Gets the center Z coordinate (depth) of the OBB.

        Returns:
            float: Z coordinate in normalized range [0.0, 1.0].

        Note:
            - Z represents depth (0=back, 1=front)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._z

    @z.setter
    def z(self, z: float) -> None: # tested (good)
        """
        Sets the center Z coordinate of the OBB.

        Args:
            z (float): The Z coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If z is not of type float.
            ValueError: If z is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(z, float):
            raise TypeError(f"'Z' must be '<float>', but got {type(z).__name__}")
        if not 0.0 <= z <= 1.0:
            raise ValueError(f"'Z' must be in [0.0, 1.0], but got {z}")
        self._z = z

    @property
    def width(self) -> float: # tested (good)
        """float: Gets the width of the OBB."""
        return self._width

    @width.setter
    def width(self, width: float) -> None: # tested (good)
        """Sets the width of the OBB.

        Args:
            width (float): Box width. Must be >= 0.0.

        Raises:
            TypeError: If width is not of type float.
            ValueError: If width is negative.

        Note:
            - Validates geometry integrity (non-negative size)
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(width, float):
            raise TypeError(f"'width' must be '<float>', but got {type(width).__name__}")
        if width < 0.0:
            raise ValueError(f"'width' must be >= 0.0, got {width}")
        self._width = width

    @property
    def height(self) -> float: # tested (good)
        """float: Gets the height of the OBB."""
        return self._height

    @height.setter
    def height(self, height: float) -> None: # tested (good)
        """Sets the height of the OBB.

        Args:
            height (float): Box height. Must be >= 0.0.

        Raises:
            TypeError: If height is not of type float.
            ValueError: If height is negative.

        Note:
            - Validates geometry integrity (non-negative size)
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(height, float):
            raise TypeError(f"'height' must be '<float>', but got {type(height).__name__}")
        if height < 0.0:
            raise ValueError(f"'height' must be >= 0.0, got {height}")
        self._height = height

    @property
    def depth(self) -> float: # tested (good)
        """float: Gets the depth of the OBB."""
        return self._depth

    @depth.setter
    def depth(self, depth: float) -> None: # tested (good)
        """Sets the depth of the OBB.

        Args:
            depth (float): Box depth. Must be >= 0.0.

        Raises:
            TypeError: If depth is not of type float.
            ValueError: If depth is negative.

        Note:
            - Validates geometry integrity (non-negative size)
            - Does not affect XY corner cache (depth is separate)
        """
        if not isinstance(depth, float):
            raise TypeError(f"'depth' must be '<float>', but got {type(depth).__name__}")
        if depth < 0.0:
            raise ValueError(f"'depth' must be >= 0.0, got {depth}")
        self._depth = depth

    @property
    def roll(self) -> float: # tested (good)
        """float: Gets the roll angle (rotation around X-axis)."""
        return self._roll

    @roll.setter
    def roll(self, roll: float) -> None: # tested (good)
        """Sets the roll angle.

        Args:
            roll (float): Roll angle in degrees.

        Raises:
            TypeError: If roll is not of type float.

        Note:
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(roll, float):
            raise TypeError(f"'roll' must be '<float>', but got {type(roll).__name__}")
        self._roll = roll

    @property
    def pitch(self) -> float: # tested (good)
        """float: Gets the pitch angle (rotation around Y-axis)."""
        return self._pitch

    @pitch.setter
    def pitch(self, pitch: float) -> None: # tested (good)
        """Sets the pitch angle.

        Args:
            pitch (float): Pitch angle in degrees.

        Raises:
            TypeError: If pitch is not of type float.

        Note:
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(pitch, float):
            raise TypeError(f"'pitch' must be '<float>', but got {type(pitch).__name__}")
        self._pitch = pitch

    @property
    def yaw(self) -> float: # tested (good)
        """float: Gets the yaw angle (rotation around Z-axis)."""
        return self._yaw

    @yaw.setter
    def yaw(self, yaw: float) -> None: # tested (good)
        """Sets the yaw angle.

        Args:
            angle (float): Yaw angle in degrees.

        Raises:
            TypeError: If angle is not of type float.

        Note:
            - Invalidates cached corners (marks points as dirty)
        """
        if not isinstance(yaw, float):
            raise TypeError(f"'yaw' must be '<float>', but got {type(yaw).__name__}")
        self._yaw = yaw

    @property
    def left(self) -> float: # TODO needs tests
        """
        float: Gets the left boundary (X min) of the axis-aligned envelope.

        Returns:
            float: Minimum X coordinate of the bounding box envelope.

        Note:
            - Computed as (center.x - width / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - Does NOT account for rotation (yaw); for rotated corners use get_corners().
        """
        return self.x - self.width / 2.0

    @property
    def right(self) -> float: # TODO needs tests
        """
        float: Gets the right boundary (X max) of the axis-aligned envelope.

        Returns:
            float: Maximum X coordinate of the bounding box envelope.

        Note:
            - Computed as (center.x + width / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - Does NOT account for rotation (yaw).
        """
        return self.x + self.width / 2.0

    @property
    def top(self) -> float: # TODO needs tests
        """
        float: Gets the top boundary (Y min) of the axis-aligned envelope.

        Returns:
            float: Minimum Y coordinate of the bounding box envelope.

        Note:
            - Computed as (center.y - height / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - In image coordinates, Y increases downward (0=top, 1=bottom).
        """
        return self.y - self.height / 2.0

    @property
    def bottom(self) -> float: # TODO needs tests
        """
        float: Gets the bottom boundary (Y max) of the axis-aligned envelope.

        Returns:
            float: Maximum Y coordinate of the bounding box envelope.

        Note:
            - Computed as (center.y + height / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - In image coordinates, Y increases downward (0=top, 1=bottom).
        """
        return self.y + self.height / 2.0

    @property
    def front(self) -> float: # TODO needs tests
        """
        float: Gets the front boundary (Z max) of the axis-aligned envelope.

        Returns:
            float: Maximum Z coordinate of the bounding box envelope.

        Note:
            - Computed as (center.z + depth / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - Z represents depth (0=back, 1=front).
        """
        return self.z + self.depth / 2.0

    @property
    def back(self) -> float: # TODO needs tests
        """
        float: Gets the back boundary (Z min) of the axis-aligned envelope.

        Returns:
            float: Minimum Z coordinate of the bounding box envelope.

        Note:
            - Computed as (center.z - depth / 2.0).
            - Represents the Axis-Aligned Bounding Box (AABB) boundary.
            - Z represents depth (0=back, 1=front).
        """
        return self.z - self.depth / 2.0

    @property
    def center(self) -> Point: # TODO needs tests
        """
        Gets the geometric center (centroid) of the OBB.

        Returns:
            Point: Center point with coordinates (X, Y, Z).
                   Clamped to [0.0, 1.0] via Point.safe().

        Note:
            - Consistent with Box.center and Polygon.center for unified interface
            - Uses canonical stored coordinates (O(1) access) rather than averaging points
            - For trajectory tracking, use bottom_center instead
        """
        return Point.safe(x=self.x, y=self.y, z=self.z)

    @property
    def bottom_center(self) -> Point: # TODO needs reBuid tests
        """
        Gets the bottom center point of the OBB.

        Calculated as (center.X, bottom.Y, center.Z).

        Returns:
            Point: Bottom center point for trajectory tracking.

        Note:
            - Consistent with Box.bottom_center and Polygon.bottom_center
            - Uses `bottom` property (max Y of rotated AABB) for accurate ground projection
            - Preserves 3D depth (Z) at the center level for volumetric consistency
        """
        return Point.safe(x=self.x, y=self.bottom, z=self.z)

    @property
    def area(self) -> float: # TODO needs reBuid tests
        """
        Gets the base area of the OBB (width × height).

        Returns:
            float: Area in normalized square units (0.0-1.0).

        Note:
            - Represents the area of the face perpendicular to the local Z-axis
            - Independent of rotation (yaw/pitch/roll) as it uses canonical dimensions
            - Consistent with Polygon.area and Box.width × Box.height for unified interface
        """
        return self._width * self._height

    @property
    def volume(self) -> float: # TODO needs reBuid tests
        """
        Gets the 3D volume of the OBB (width × height × depth).

        Returns:
            float: Volume in normalized cubic units (0.0-1.0).

        Note:
            - Represents the actual volumetric space occupied by the canonical box
            - Independent of rotation; calculated directly from intrinsic dimensions
            - Consistent with Box.get_volume() for spatial reasoning and ADAS pipelines
        """
        return self._width * self._height * self._depth

    def scale(self, sx: float, sy: float, sz: float = 1.0, normalized: bool = True) -> 'Obb': # TODO needs reBuid tests
        """
        Returns a new Obb scaled by the given factors.

        Args:
            sx, sy, sz: Scale factors. Accepts int or float.
                        Positive: standard scaling.
                        Negative: reflection/mirroring (handled by coordinate clamping if normalized).
                        Zero: collapses dimension.
            normalized: If True (default), clamps result to [0.0, 1.0].
                        If False, returns absolute coordinates without clamping.

        Returns:
            Obb: Scaled OBB. Original instance remains unchanged.
        """
        for val, name in [(sx, 'sx'), (sy, 'sy'), (sz, 'sz')]:
            if isinstance(val, bool) or not isinstance(val, (int, float)):
                raise TypeError(f"'{name}' must be numeric, got {type(val).__name__}")


        if normalized:
            return Obb.safe(x=self.x * sx, y=self.y * sy, z=self.z * sz,
                            width=max(0.0, abs(self.width * sx)),
                            height= max(0.0, abs(self.height * sy)),
                            depth=max(0.0, abs(self.depth * sz)),
                            yaw=self.yaw, pitch=self.pitch, roll=self.roll)
        else:
            return Obb._from_raw(x=self.x * sx, y=self.y * sy, z=self.z * sz,
                                 width=max(0.0, abs(self.width * sx)),
                                 height= max(0.0, abs(self.height * sy)),
                                 depth=max(0.0, abs(self.depth * sz)),
                                 yaw=self.yaw, pitch=self.pitch, roll=self.roll)

    def translate(self, dx: float, dy: float, dz: float = 0.0, normalized: bool = True) -> 'Obb': # TODO needs reBuid tests
        """
        Returns a new Obb translated by the given offsets.

        Args:
            dx, dy, dz: Translation offsets (can be negative/positive).
            normalized: If True (default), clamps result to [0.0, 1.0].
                        If False, allows coordinates to exceed bounds (e.g., ROI padding).

        Returns:
            Obb: Translated OBB. Original instance remains unchanged.
        """
        for val, name in [(dx, 'dx'), (dy, 'dy'), (dz, 'dz')]:
            if isinstance(val, bool) or not isinstance(val, (int, float)):
                raise TypeError(f"'{name}' must be numeric, got {type(val).__name__}")

        if normalized:
            return Obb.safe(x=self.x + dx, y=self.y + dy, z=self.z + dz,
                            width=self.width, height=self.height, depth=self.depth,
                            yaw=self.yaw, pitch=self.pitch, roll=self.roll)
        else:
            return Obb._from_raw(x=self.x + dx, y=self.y + dy, z=self.z + dz,
                                 width=self.width, height=self.height, depth=self.depth,
                                 yaw=self.yaw, pitch=self.pitch, roll=self.roll)

    # =========================================================================
    # 2D ORIENTED CONVERSIONS
    # =========================================================================
    def to_xyxyn(self) -> tuple[float, float, float, float]: # tested (good)
        """
        Converts the OBB to YOLO's normalized xyxy format: [x_min, y_min, x_max, y_max].

        Returns:
            tuple: (left, top, right, bottom) in normalized coordinates [0.0, 1.0].

        Note:
            - Matches Ultralytics YOLO 'xyxyn' output format exactly.
            - Z coordinates are intentionally excluded as YOLO operates in 2D.
            - Boundaries are derived from canonical center ± half-dimensions.
            - Useful for exporting to annotation tools, training pipelines, or API responses.
        """
        return (self.left, self.top, self.right, self.bottom)

    @classmethod
    def from_xyxyn(cls, xyxyn: Union[list, tuple, np.ndarray]) -> 'Obb':  # tested (good)
        """
        Creates an OBB from YOLO's normalized xyxy format: [x_min, y_min, x_max, y_max].

        Args:
            xyxyn: Iterable of 4 normalized floats [left, top, right, bottom].

        Returns:
            Obb: New instance with yaw/pitch/roll set to 0.0 and z/depth to 0.0.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 4.

        Note:
            - Converts AABB boundaries to canonical center-size representation.
            - Rotation angles are set to 0.0 (AABB implies no rotation).
            - Handles inverted coordinates gracefully (calculates absolute dimensions).
        """
        if not isinstance(xyxyn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyxyn).__name__}")
        if len(xyxyn) != 4:
            raise ValueError(f"Expected 4 values [left, top, right, bottom], got {len(xyxyn)}")

        left, top, right, bottom = xyxyn
        return cls.safe(x=(left + right) / 2.0, y=(top + bottom) / 2.0,
                        width=abs(right - left), height=abs(bottom - top))

    def to_xywhn(self) -> tuple[float, float, float, float]:  # tested (good)
        """
        Converts the OBB to YOLO's normalized xywh format: [x_center, y_center, width, height].

        Returns:
            tuple: (center_x, center_y, width, height) in normalized coordinates.

        Note:
            - Matches Ultralytics YOLO 'xywhn' output format.
            - Directly maps canonical x, y, width, height (O(1) calculation).
            - Note: Rotation (yaw) is intentionally excluded from this 2D projection.
        """
        return (self.x, self.y, self.width, self.height)

    @classmethod
    def from_xywhn(cls, xywhn: Union[list, tuple, np.ndarray]) -> 'Obb':  # tested (good)
        """
        Creates an OBB from YOLO's normalized xywh format: [x_center, y_center, width, height].

        Args:
            xywhn: Iterable of 4 normalized floats [cx, cy, w, h].

        Returns:
            Obb: New instance with yaw/pitch/roll set to 0.0 and z/depth to 0.0.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 4.

        Note:
            - Direct mapping to canonical center-size parameters.
            - Rotation angles are set to 0.0.
        """
        if not isinstance(xywhn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xywhn).__name__}")
        if len(xywhn) != 4:
            raise ValueError(f"Expected 4 values [cx, cy, w, h], got {len(xywhn)}")

        center_x, center_y, width, height = xywhn
        return cls.safe(x=center_x, y=center_y, width=width, height=height)

    def to_xywhrn(self) -> tuple[float, float, float, float, float]: # tested (good)
        """
        Converts the OBB to YOLO's normalized xywhr format: [x_center, y_center, w, h, r].

        Returns:
            tuple: (x, y, width, height, angle_in_radians) in normalized coordinates.

        Note:
            - YOLO natively uses RADIANS for the angle 'r'.
            - This converts Obb's internal degrees to radians (math.radians).
            - Compatible with `result.obb.xywhr` and standard training datasets.
        """
        return (self.x, self.y, self.width, self.height, math.radians(self.yaw))

    @classmethod
    def from_xywhrn(cls, xywhrn: Union[list, tuple, np.ndarray]) -> 'Obb': # tested (good)
        """
        Creates an OBB from YOLO's normalized 2D center-size-angle format.

        Args:
            xywhrn: Iterable of 5 floats [center_x, center_y, width, height, angle_rad].

        Returns:
            Obb: New instance with angle converted from radians to degrees.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 5.

        Note:
            - Standard YOLO angle 'r' is in RADIANS. This method converts it to DEGREES.
            - Z/depth/pitch/roll default to 0.0 for 2D compatibility.
        """
        if not isinstance(xywhrn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xywhrn).__name__}")
        if isinstance(xywhrn, np.ndarray):
            xywhrn = xywhrn.tolist()
        if len(xywhrn) != 5:
            raise ValueError(f"Expected 5 values [cx, cy, w, h, r], got {len(xywhrn)}")

        center_x, center_y, width, height, yaw = xywhrn
        return cls.safe(x=center_x, y=center_y, width=width, height=height, yaw=math.degrees(yaw))


    # =========================================================================
    # 3D ORIENTED CONVERSIONS
    # =========================================================================
    def to_xyzwhdn(self) -> tuple[float, float, float, float, float, float]:  # tested (good)
        """
        Converts the 3D OBB to normalized center-size format: [cx, cy, cz, w, h, d].

        Returns:
            tuple: (center_x, center_y, center_z, width, height, depth) in normalized coordinates.

        Note:
            - Standard format for 3D object detection and robotic spatial reasoning.
            - Directly maps canonical position and dimensions.
            - Note: Orientation (yaw/pitch/roll) is not encoded in this center-size format.
        """
        return (self.x, self.y, self.z, self.width, self.height, self.depth)

    @classmethod
    def from_xyzwhdn(cls, xyzwhdn: Union[list, tuple, np.ndarray]) -> 'Obb':  # tested (good)
        """
        Creates a 3D OBB from normalized center-size format [cx, cy, cz, w, h, d].

        Args:
            xyzwhdn: Iterable of 6 floats [center_x, center_y, center_z, width, height, depth].

        Returns:
            Obb: New instance with yaw/pitch/roll set to 0.0.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 6.

        Note:
            - Direct mapping to canonical parameters.
            - Rotation angles are set to 0.0.
        """
        if not isinstance(xyzwhdn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzwhdn).__name__}")
        if len(xyzwhdn) != 6:
            raise ValueError(f"Expected 6 values [cx, cy, cz, w, h, d], got {len(xyzwhdn)}")

        center_x, center_y, center_z, width, height, depth = xyzwhdn
        return cls.safe(x=center_x, y=center_y, z=center_z,
                        width=width, height=height, depth=depth)

    def to_xyzxyzn(self) -> tuple[float, float, float, float, float, float]:  # tested (good)
        """
        Converts the 3D OBB to normalized corner format: [x1, y1, z1, x2, y2, z2].

        Returns:
            tuple: (left, top, front, right, bottom, back) in normalized coordinates.

        Note:
            - Matches standard 3D detection formats (KITTI, NuScenes, 3D-YOLO).
            - front = Z max (closer to camera), back = Z min (farther from camera).
            - Boundaries are derived from canonical center ± half-dimensions.
        """
        return (self.left, self.top, self.front, self.right, self.bottom, self.back)

    @classmethod
    def from_xyzxyzn(cls, xyzxyzn: Union[list, tuple, np.ndarray]) -> 'Obb':  # tested (good)
        """
        Creates a 3D OBB from normalized corner coordinates [x1, y1, z1, x2, y2, z2].

        Args:
            xyzxyzn: Iterable of 6 floats [left, top, front, right, bottom, back].

        Returns:
            Obb: New instance with yaw/pitch/roll set to 0.0.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 6.

        Note:
            - Converts AABB boundaries to canonical center-size representation.
            - Rotation angles are set to 0.0 (AABB implies no rotation).
            - Handles inverted coordinates gracefully.
        """
        if not isinstance(xyzxyzn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzxyzn).__name__}")
        if len(xyzxyzn) != 6:
            raise ValueError(f"Expected 6 values [x1, y1, z1, x2, y2, z2], got {len(xyzxyzn)}")

        left, top, front, right, bottom, back = xyzxyzn
        return cls.safe(x=(left + right) / 2.0, y=(top + bottom) / 2.0, z=(front + back) / 2.0,
                        width=abs(right - left), height=abs(bottom - top), depth=abs(front - back))

    def to_xyzwhdyprn(self) -> tuple[float, float, float, float, float, float, float, float, float]: # tested (good)
        """
        Converts the OBB to normalized 3D format: [cx, cy, cz, w, h, d, yaw, pitch, roll].

        Returns:
            tuple: 9 floats representing canonical position, dimensions, and Euler angles (in radians).

        Note:
            - Designed for robotics, 3D datasets (KITTI, NuScenes), and multi-sensor fusion.
            - All angles (yaw, pitch, roll) are exported in RADIANS for external compatibility.
        """
        return (self.x, self.y, self.z, self.width, self.height, self.depth,
                math.radians(self.yaw), math.radians(self.pitch), math.radians(self.roll))

    @classmethod
    def from_xyzwhdyprn(cls, xyzwhdyprn: Union[list, tuple, np.ndarray]) -> 'Obb': # tested (good)
        """
        Creates an OBB from normalized 3D center-size-Euler format.

        Args:
            xyzwhdyprn: Iterable of 9 floats [cx, cy, cz, w, h, d, yaw_rad, pitch_rad, roll_rad].

        Returns:
            Obb: New instance with angles converted from radians to degrees.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 9.

        Note:
            - Assumes standard aerospace rotation order: Yaw (Z) → Pitch (Y) → Roll (X).
            - Coordinates are assumed to be normalized [0.0, 1.0].
        """
        if not isinstance(xyzwhdyprn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzwhdyprn).__name__}")
        if isinstance(xyzwhdyprn, np.ndarray):
            xyzwhdyprn = xyzwhdyprn.tolist()
        if len(xyzwhdyprn) != 9:
            raise ValueError(f"Expected 9 values [cx, cy, cz, w, h, d, yaw, pitch, roll], got {len(xyzwhdyprn)}")

        center_x, center_y, center_z, width, height, depth, yaw_rad, pitch_rad, roll_rad = xyzwhdyprn
        return cls.safe(x=center_x, y=center_y, z=center_z,
                        width=width, height=height, depth=depth,
                        yaw=math.degrees(yaw_rad), pitch=math.degrees(pitch_rad), roll=math.degrees(roll_rad))

    @classmethod
    def safe(cls,
             x: float = 0.0, y: float = 0.0, z: float = 0.0,
             width: float = 0.0, height: float = 0.0, depth: float = 0.0,
             yaw: float = 0.0, pitch: float = 0.0, roll: float = 0.0,
             min_val: float = 0.0, max_val: float = 1.0) -> 'Obb': # tested (good)
        """
        Creates an Obb with coordinates clamped to [min_val, max_val] range.

        Bypasses __init__ validation to avoid double-checking and support custom ranges.

        Note:
            - No exceptions raised. Coordinates are clamped independently per axis.
            - Dimensions are clamped but forced to >= 0.0 for geometric validity.
            - Rotational angles (yaw/pitch/roll) are assigned directly without spatial clamping.
            - Recommended for geometric transformations that may exit normalized space.
        """
        def clamp(v: float) -> float:
            return float(min_val if v < min_val else (max_val if v > max_val else v))

        obb = cls.__new__(cls)
        obb._x = clamp(x)
        obb._y = clamp(y)
        obb._z = clamp(z)
        obb._width = max(0.0, clamp(width))
        obb._height = max(0.0, clamp(height))
        obb._depth = max(0.0, clamp(depth))
        obb._yaw = float(yaw)
        obb._pitch = float(pitch)
        obb._roll = float(roll)
        return obb

    @classmethod
    def _from_raw(cls,
                  x: float = 0.0, y: float = 0.0, z: float = 0.0,
                  width: float = 0.0, height: float = 0.0, depth: float = 0.0,
                  yaw: float = 0.0, pitch: float = 0.0, roll: float = 0.0) -> 'Obb': # tested (good)
        """Internal factory to create Obb without __init__ validation or clamping."""
        obb = cls.__new__(cls)
        obb._x = float(x)
        obb._y = float(y)
        obb._z = float(z)
        obb._width = max(0.0, float(width))
        obb._height = max(0.0, float(height))
        obb._depth = max(0.0, float(depth))
        obb._yaw = float(yaw)
        obb._pitch = float(pitch)
        obb._roll = float(roll)
        return obb


