# Copyright (c) 2026 Luigi Paone <ppc.luigi.paone@gmail.com>
# SPDX-License-Identifier: BSD-3-Clause

"""Geometric primitives for 2D and 3D structural analysis.

Provides points, vectors, nodes, segments, edges, and polylines in 2D and 3D
space, along with applied-vector and force-couple representations used
throughout the PyCivil structural pipeline.

Typical units follow the PyCivil convention: millimetres (mm) for lengths,
Newtons (N) for forces, and MPa for stresses.
"""
from __future__ import annotations

import math
from copy import deepcopy
from typing import List, Optional, Tuple, Union

from pydantic import BaseModel


# Defines points or free vector in 2D space
class Point2d:
    """A point (or free vector) in 2D Cartesian space.

    Coordinates are stored as private attributes and exposed via properties.
    The class supports basic arithmetic (``+``, ``-``, scalar ``*``), equality
    testing, and common geometric operations such as rotation, translation, and
    signed-area calculation.

    Attributes:
        x: X coordinate.
        y: Y coordinate.
    """

    def __init__(
        self,
        x: Union[float, int] = 0.0,
        y: Union[float, int] = 0.0,
        make_null: bool = False,
        coords: Union[Tuple[Union[float, int], Union[float, int]], None] = None,
    ):
        """Initialise a Point2d.

        Args:
            x: X coordinate.  Ignored when *coords* is provided.
            y: Y coordinate.  Ignored when *coords* is provided.
            make_null: When ``True`` the point is flagged as a *null* sentinel
                (e.g. to signal a missing intersection result).
            coords: Optional ``(x, y)`` tuple.  Takes precedence over the
                individual *x* / *y* arguments when supplied.

        Raises:
            ValueError: If *x* is not a ``float`` or ``int``.
        """
        if isinstance(x, (float, int)):
            self.__x = x
            self.__y = y
            self.__isNull: bool = make_null
        else:
            raise ValueError("Point2d init only with float or int !!!")

        if coords is not None:
            self.__x = coords[0]
            self.__y = coords[1]

    def __str__(self):
        return f"Point2d: ({self.__x:.2e},{self.__y:.2e})"

    def __add__(self, other: Point2d) -> Point2d:
        return Point2d(self.__x + other.x, self.__y + other.y)

    def __sub__(self, other: Point2d) -> Point2d:
        return Point2d(self.__x - other.x, self.__y - other.y)

    def __mul__(self, other: Union[float, int]) -> Point2d:
        return Point2d(self.__x * other, self.__y * other)

    def __rmul__(self, other: float) -> Point2d:
        return Point2d(self.__x * other, self.__y * other)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Point2d):
            return NotImplemented
        if self.__x == other.x and self.__y == other.y:
            return True
        else:
            return False

    def __repr__(self):
        return str(self)

    @property
    def x(self):
        """float: X coordinate."""
        return self.__x

    @x.setter
    def x(self, val: float) -> None:
        self.__x = val

    @property
    def y(self):
        """float: Y coordinate."""
        return self.__y

    @y.setter
    def y(self, val: float) -> None:
        self.__y = val

    def isNull(self) -> bool:
        """Return ``True`` if this point was created as a null sentinel.

        Returns:
            bool: ``True`` when the point carries no geometric meaning.
        """
        return self.__isNull

    def isEqualTo(self, other, rox, roy, prec):
        """Test approximate equality with reference scales and a tolerance.

        The comparison is performed in normalised coordinates: each difference
        is divided by the corresponding reference length before checking
        against *prec*.

        Args:
            other: Another ``Point2d`` to compare against.
            rox: Reference length along the X axis used for normalisation.
            roy: Reference length along the Y axis used for normalisation.
            prec: Tolerance threshold in normalised units.

        Returns:
            bool: ``True`` if both normalised differences are smaller than
                *prec*.
        """
        dx = math.sqrt(math.pow(self.__x - other.x, 2) / math.pow(rox, 2))
        dy = math.sqrt(math.pow(self.__y - other.y, 2) / math.pow(roy, 2))
        if dx < prec and dy < prec:
            return True
        else:
            return False

    def distance(self, other) -> float:
        """Return the Euclidean distance to another point.

        Args:
            other: Target ``Point2d``.

        Returns:
            float: Distance between the two points.
        """
        return math.sqrt(
            math.pow(self.__x - other.x, 2) + math.pow(self.__y - other.y, 2)
        )

    def midpoint(self, other) -> Point2d:
        """Return the midpoint between this point and *other*.

        Args:
            other: The second ``Point2d``.

        Returns:
            Point2d: The midpoint.
        """
        return Point2d((self.__x + other.x) / 2, (self.__y + other.y) / 2)

    def cross(self, p1, p2) -> float:
        """Compute the signed cross product (z-component) relative to this point.

        Geometrically this equals twice the signed area of the triangle
        ``(self, p1, p2)``.  A positive result means *p2* is to the left of
        the directed edge ``self → p1``.

        Args:
            p1: First ``Point2d``.
            p2: Second ``Point2d``.

        Returns:
            float: Signed cross product value.
        """
        x1 = p1.x - self.__x
        y1 = p1.y - self.__y

        x2 = p2.x - self.__x
        y2 = p2.y - self.__y

        return x1 * y2 - x2 * y1

    def affine(self, m11: float, m12: float, m21: float, m22: float, dx: float, dy: float) -> Point2d:
        """Apply a general 2-D affine transformation in-place.

        The transformation is defined as::

            x' = m11*x + m12*y + dx
            y' = m21*x + m22*y + dy

        Args:
            m11: Element (1,1) of the linear part.
            m12: Element (1,2) of the linear part.
            m21: Element (2,1) of the linear part.
            m22: Element (2,2) of the linear part.
            dx: Translation in X.
            dy: Translation in Y.

        Returns:
            Point2d: *self* after the transformation (allows chaining).
        """
        x_old = self.__x
        y_old = self.__y
        self.__x = m11 * x_old + m12 * y_old + dx
        self.__y = m21 * x_old + m22 * y_old + dy
        return self

    def rotate(self, angle: float, center: Point2d) -> Point2d:
        """Rotate the point in-place around a given centre.

        Args:
            angle: Rotation angle in **degrees** (positive = counter-clockwise).
            center: Centre of rotation.

        Returns:
            Point2d: *self* after the rotation (allows chaining).
        """
        alpha = math.radians(angle)
        self.affine(1, 0, 0, 1, -center.x, -center.y)
        self.affine(math.cos(alpha), -math.sin(alpha), math.sin(alpha), math.cos(alpha), 0, 0)
        self.affine(1, 0, 0, 1, +center.x, +center.y)
        return self

    def translate(self, dx: float, dy: float) -> Point2d:
        """Translate the point in-place.

        Args:
            dx: Displacement along the X axis.
            dy: Displacement along the Y axis.

        Returns:
            Point2d: *self* after the translation (allows chaining).
        """
        self.affine(1, 0, 0, 1, dx, dy)
        return self

    @staticmethod
    def areaFromTria(p0: Point2d, p1: Point2d, p2: Point2d) -> float:
        """Return the area of a triangle defined by three points.

        Uses the standard cross-product (shoelace) formula.

        Args:
            p0: First vertex.
            p1: Second vertex.
            p2: Third vertex.

        Returns:
            float: Non-negative area of the triangle.
        """
        assert isinstance(p0.x, (float, int))
        assert isinstance(p0.y, (float, int))
        assert isinstance(p1.x, (float, int))
        assert isinstance(p1.y, (float, int))
        assert isinstance(p2.x, (float, int))
        assert isinstance(p2.y, (float, int))
        return (
            1
            / 2
            * abs(
                p0.x * p1.y * 1
                + p0.y * 1 * p2.x
                + 1 * p1.x * p2.y
                - 1 * p1.y * p2.x
                - p0.y * p1.x * 1
                - p0.x * 1 * p2.y
            )
        )


def boundingBox(p1, p2):
    """Return the axis-aligned bounding box of two 2-D points.

    Args:
        p1: First ``Point2d``.
        p2: Second ``Point2d``.

    Returns:
        list[float]: ``[min_x, max_x, min_y, max_y]``.

    Raises:
        Exception: If either argument is not a ``Point2d``.
    """
    if not isinstance(p1, Point2d) or not isinstance(p2, Point2d):
        raise Exception("Bounding only for two Point2d !!!")

    assert isinstance(p1.x, (float, int))
    assert isinstance(p1.y, (float, int))
    assert isinstance(p2.x, (float, int))
    assert isinstance(p2.y, (float, int))

    min_x = min(p1.x, p2.x)
    max_x = max(p1.x, p2.x)
    min_y = min(p1.y, p2.y)
    max_y = max(p1.y, p2.y)

    return [min_x, max_x, min_y, max_y]


class Node2d(Point2d):
    """A 2-D mesh node extending ``Point2d`` with an integer identifier.

    Attributes:
        idn: Integer node identifier (default ``-1`` for unassigned).
    """

    def __init__(self, x=0.0, y=0.0, idn=-1):
        """Initialise a Node2d.

        Args:
            x: X coordinate.
            y: Y coordinate.
            idn: Node identifier.
        """
        Point2d.__init__(self, x, y)
        self.idn = idn

    def __str__(self):
        return f"Node2D: idn = {self.idn:.0f}, x = {self.__x:.3e}, y = {self.__y:.3e}"


# Defines points or free vector in 3D space
class Point3d:
    """A point (or free vector) in 3D Cartesian space.

    Attributes:
        x: X coordinate.
        y: Y coordinate.
        z: Z coordinate.
    """

    def __init__(self, x=0.0, y=0.0, z=0.0):
        """Initialise a Point3d.

        Args:
            x: X coordinate.
            y: Y coordinate.
            z: Z coordinate.
        """
        self.__x = x
        self.__y = y
        self.__z = z

    def __str__(self) -> str:
        return f"Point3D: x = {self.__x:.3e}, y = {self.__y:.3e}, z = {self.__z:.3e}"

    def __add__(self, other: object) -> Point3d:
        if not isinstance(other, Point3d):
            return NotImplemented

        return Point3d(self.__x + other.x, self.__y + other.y, self.z + other.z)

    def __sub__(self, other):
        return Point3d(self.__x - other.x, self.__y - other.y, self.z - other.z)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Point3d):
            return NotImplemented

        if self.__x == other.x and self.__y == other.y and self.__z == other.z:
            return True
        else:
            return False

    @property
    def x(self):
        """float: X coordinate."""
        return self.__x

    @x.setter
    def x(self, val: float) -> None:
        self.__x = val

    @property
    def y(self):
        """float: Y coordinate."""
        return self.__y

    @y.setter
    def y(self, val: float) -> None:
        self.__y = val

    @property
    def z(self):
        """float: Z coordinate."""
        return self.__z

    @z.setter
    def z(self, val: float) -> None:
        self.__z = val

    def tranlate(self, dx=0.0, dy=0.0, dz=0.0):
        """Translate the point in-place.

        Args:
            dx: Displacement along the X axis.
            dy: Displacement along the Y axis.
            dz: Displacement along the Z axis.
        """
        self.__x = self.__x + dx
        self.__y = self.__y + dy
        self.z = self.z + dz

    def distanceFrom(self, p: Point3d) -> float:
        """Return the Euclidean distance to another 3-D point.

        Args:
            p: Target ``Point3d``.

        Returns:
            float: Distance between the two points.

        Raises:
            Exception: If *p* is not a ``Point3d`` instance.
        """
        if not isinstance(p, Point3d):
            raise Exception("Arg must be a Point3d instance !!!")

        return math.dist([self.__x, self.__y, self.z], [p.x, p.y, p.z])


def areaFromTria3D(p0: Point3d, p1: Point3d, p2: Point3d) -> float:
    """Return the area of a 3-D triangle defined by three points.

    Uses the cross-product formula:
    ``area = 0.5 * |v × w|``
    where *v* = ``p0→p1`` and *w* = ``p1→p2``.

    Args:
        p0: First vertex.
        p1: Second vertex.
        p2: Third vertex.

    Returns:
        float: Non-negative area of the triangle.
    """
    v = Vector3d(p0, p1)
    w = Vector3d(p1, p2)
    return 1 / 2 * math.sqrt(abs(v.scalar(v) * w.scalar(w) - v.scalar(w) * v.scalar(w)))


class Node3d(Point3d):
    """A 3-D mesh node extending ``Point3d`` with an integer identifier.

    Attributes:
        idn: Integer node identifier (default ``-1`` for unassigned).
    """

    def __init__(self, x=0.0, y=0.0, z=0.0, idn=-1):
        """Initialise a Node3d.

        Args:
            x: X coordinate.
            y: Y coordinate.
            z: Z coordinate.
            idn: Node identifier.
        """
        Point3d.__init__(self, x, y, z)
        self.idn = idn

    def __str__(self) -> str:
        return "Node3D: idn = {:.0f}, x = {:.3e}, y = {:.3e}, z = {:.3e}".format(
            self.idn,
            self.x,
            self.y,
            self.z,
        )

    def __add__(self, other: object) -> Node3d:
        if not isinstance(other, Node3d):
            return NotImplemented

        return Node3d(self.__x + other.x, self.__y + other.y, self.z + other.z)

    def __sub__(self, other):
        return Node3d(self.__x - other.x, self.__y - other.y, self.z - other.z)


class Seg2d:
    """A directed line segment in 2-D space defined by two ``Point2d`` endpoints.

    Provides intersection, containment, and factor utilities needed for
    polygon clipping and section outline processing.

    Attributes:
        p0: Start point of the segment.
        p1: End point of the segment.
    """

    def __init__(self, p0: Point2d, p1: Point2d):
        """Initialise a Seg2d from two endpoints.

        Args:
            p0: Start point.
            p1: End point.
        """
        self.__p0 = p0
        self.__p1 = p1

    @property
    def p0(self):
        """Point2d: Start point."""
        return self.__p0

    @p0.setter
    def p0(self, val: Point2d) -> None:
        self.__p0 = val

    @property
    def p1(self):
        """Point2d: End point."""
        return self.__p1

    @p1.setter
    def p1(self, val: Point2d) -> None:
        self.__p1 = val

    def len(self) -> float:
        """Return the length of the segment.

        Returns:
            float: Euclidean distance between *p0* and *p1*.
        """
        return self.__p0.distance(self.__p1)

    # Find the intersection point by Seg2D in input
    def intersect(self, other: Seg2d) -> Point2d:
        """Return the intersection point of the two infinite lines.

        The lines are the infinite extensions of *self* and *other*.  If the
        lines are parallel (determinant == 0) a null ``Point2d`` is returned.

        Args:
            other: The second segment (treated as an infinite line).

        Returns:
            Point2d: Intersection point, or a null ``Point2d`` if parallel.
        """
        a0 = self.__p1.x - self.__p0.x
        b0 = other.p0.x - other.p1.x
        c0 = other.p0.x - self.__p0.x

        a1 = self.__p1.y - self.__p0.y
        b1 = other.p0.y - other.p1.y
        c1 = other.p0.y - self.__p0.y

        detA = a0 * b1 - a1 * b0
        if detA == 0:
            return Point2d(make_null=True)

        detA_alpha = c0 * b1 - c1 * b0
        alpha = detA_alpha / detA
        return self.__p0 + alpha * (self.__p1 - self.__p0)

    def boundingHasPoint(self, p: Point2d, toll: float = 1e-15) -> bool:
        """Test whether *p* lies within the axis-aligned bounding box of this segment.

        Args:
            p: The point to test.
            toll: Absolute tolerance added to the bounding box extents.

        Returns:
            bool: ``True`` if *p* is inside (or on) the expanded bounding box.
        """
        bb = boundingBox(self.__p0, self.__p1)
        if (
            p.x < bb[0] - toll
            or p.x > bb[1] + toll
            or p.y < bb[2] - toll
            or p.y > bb[3] + toll
        ):
            return False
        else:
            return True

    # Calculate intersection point from extension of other and self
    def intersectAndContaints(self, other: Seg2d) -> Tuple[bool, Point2d]:
        """Intersect two segments and report whether the intersection lies on *self*.

        Uses a length-ratio test: the intersection belongs to *self* when the
        sum of its distances to *p0* and *p1* equals the segment length
        (within numerical tolerance).

        Args:
            other: The second segment.

        Returns:
            Tuple[bool, Point2d]: A ``(contained, point)`` pair where
                *contained* is ``True`` if the intersection point lies on
                *self*, and *point* is the intersection ``Point2d``.
        """
        pint = self.intersect(other)
        m1 = (pint.distance(self.__p0) + pint.distance(self.__p1))/ self.__p0.distance(self.__p1)
        # m2 = (pint.distance(other.p0) + pint.distance(other.p1)) / other.len()
        pint.isNull()
        if abs(m1-1) < 0.0000000001:
            return True, pint
        else:
            return False, pint

        # if not pint.isNull() and self.boundingHasPoint(pint, toll=self.len() / 100000):
        #     return True, pint
        # else:
        #     return False, pint

    # If p is a Point2d calculs factor witch factor * Seg2d = p
    def extensionFactor(self, p) -> float:
        """Compute the scalar factor such that ``factor * self == p``.

        Useful for parametric position queries along the infinite line through
        *self*.  The Y component is preferred when the X component of the
        segment direction is zero.

        Args:
            p: Target ``Point2d``.

        Returns:
            float: Extension factor along the segment direction.
        """
        vx = self.__p0.x - self.__p1.x
        vy = self.__p0.y - self.__p1.y

        evx = p.x - self.__p1.x
        evy = p.y - self.__p1.y

        f = 1
        if vx != 0.0:
            f = evx / vx
        if vy != 0.0:
            f = evy / vy
        return f

    def __str__(self):
        dispstr = "Point2d i: x = {:.3e}, y = {:.3e} \n".format(
            self.__p0.x,
            self.__p0.y,
        )
        dispstr = dispstr + "Point2d j: x = {:.3e}, y = {:.3e} \n".format(
            self.__p1.x,
            self.__p1.y,
        )
        return dispstr


class Edge2d:
    """A directed edge in a 2-D mesh connecting two ``Node2d`` instances.

    Unlike ``Seg2d``, an ``Edge2d`` carries node identifiers and is intended
    for use in mesh connectivity structures.
    """

    def __init__(self, *args):
        """Initialise an Edge2d from exactly two ``Node2d`` arguments.

        Args:
            *args: Exactly two ``Node2d`` instances (node_i, node_j).

        Raises:
            TypeError: If the arguments are not two ``Node2d`` instances.
        """
        if len(args) == 2:
            if isinstance(args[0], Node2d) & isinstance(args[1], Node2d) is True:
                self.__node_i = args[0]
                self.__node_j = args[1]
            else:
                raise TypeError("Args must be [Node2D] !!!")
        else:
            raise TypeError("Only two argument !!!")

    def nodeI(self) -> Node2d:
        """Return the start node of the edge.

        Returns:
            Node2d: Start node.
        """
        return self.__node_i

    def nodeJ(self) -> Node2d:
        """Return the end node of the edge.

        Returns:
            Node2d: End node.
        """
        return self.__node_j

    def __str__(self):
        dispstr = "Node2D i: idn = {:.0f}, x = {:.3e}, y = {:.3e} \n".format(
            self.__node_i.idn,
            self.__node_i.x,
            self.__node_i.y,
        )
        dispstr = dispstr + "Node2D j: idn = {:.0f}, x = {:.3e}, y = {:.3e} \n".format(
            self.__node_j.idn,
            self.__node_j.x,
            self.__node_j.y,
        )
        return dispstr


class Edge3d:
    """A directed edge in a 3-D mesh connecting two ``Node3d`` instances.

    Supports three construction signatures:

    * No arguments — creates an edge with two default (origin) nodes.
    * Two ``Node3d`` instances — ``Edge3d(node_i, node_j)``.
    * Eight numeric + id values — ``Edge3d(xi, yi, zi, idni, xj, yj, zj, idnj)``.
    """

    def __init__(self, *args):
        """Initialise an Edge3d.

        Args:
            *args: One of the following signatures:

                * ``()`` — default construction, both nodes at the origin.
                * ``(Node3d, Node3d)`` — explicit node pair.
                * ``(float, float, float, int, float, float, float, int)`` —
                  coordinates and identifiers for node I followed by node J.

        Raises:
            Exception: On invalid argument types or counts.
        """
        if len(args) == 2:
            if isinstance(args[0], Node3d) & isinstance(args[1], Node3d) is True:
                self.__node_i = args[0]
                self.__node_j = args[1]
            else:
                raise Exception("Args must be [Node3D] !!!")
        elif len(args) == 0:
            self.__node_i = Node3d()
            self.__node_j = Node3d()
        elif len(args) == 8:
            for i in range(2):
                if not isinstance(args[0 + 4 * i], float):
                    raise Exception("Arg n. %e must be float !!!" % (0 + i))
                if not isinstance(args[1 + 4 * i], float):
                    raise Exception("Arg n. %e must be float !!!" % (1 + i))
                if not isinstance(args[2 + 4 * i], float):
                    raise Exception("Arg n. %e must be float !!!" % (2 + i))
                if not isinstance(args[3 + 4 * i], int):
                    raise Exception("Arg n. %e must be int !!!" % (3 + i))
            self.__node_i = Node3d(args[0], args[1], args[2], args[3])
            self.__node_j = Node3d(args[4], args[5], args[6], args[7])
        else:
            raise Exception("Wrong arguments !!!")

    def nodeI(self) -> Node3d:
        """Return the start node of the edge.

        Returns:
            Node3d: Start node.
        """
        return self.__node_i

    def nodeJ(self) -> Node3d:
        """Return the end node of the edge.

        Returns:
            Node3d: End node.
        """
        return self.__node_j

    def setNodeI(self, *args):
        """Set the start node.

        Args:
            *args: Exactly one ``Node3d`` instance.

        Raises:
            Exception: If the argument is not a single ``Node3d``.
        """
        if len(args) == 1:
            if isinstance(args[0], Node3d):
                self.__node_i = args[0]
            else:
                raise Exception("Arg must be [Node3D] !!!")
        else:
            raise Exception("Only one argument !!!")

    def setNodeJ(self, *args):
        """Set the end node.

        Args:
            *args: Exactly one ``Node3d`` instance.

        Raises:
            Exception: If the argument is not a single ``Node3d``.
        """
        if len(args) == 1:
            if isinstance(args[0], Node3d):
                self.__node_j = args[0]
            else:
                raise Exception("Arg must be [Node3D] !!!")
        else:
            raise Exception("Only one argument !!!")

    def __str__(self):
        dispstr = (
            "Node3D i: idn = {:.0f}, x = {:.3e}, y = {:.3e}, z = {:.3e} \n".format(
                self.__node_i.idn,
                self.__node_i.x,
                self.__node_i.y,
                self.__node_i.z,
            )
        )
        dispstr = (
            dispstr
            + "Node3D j: idn = {:.0f}, x = {:.3e}, y = {:.3e}, z = {:.3e} \n".format(
                self.__node_j.idn,
                self.__node_j.x,
                self.__node_j.y,
                self.__node_j.z,
            )
        )
        return dispstr

    def lenght(self) -> float:
        """Return the length of the edge.

        Returns:
            float: Euclidean distance between node I and node J.
        """
        return math.sqrt(
            math.pow(self.__node_j.x - self.__node_i.x, 2)
            + math.pow(self.__node_j.y - self.__node_i.y, 2)
            + math.pow(self.__node_j.z - self.__node_i.z, 2)
        )


class Polyline2d:
    """List of Nodes2D

    An ordered sequence of ``Node2d`` vertices representing an open or closed
    2-D polyline (polygon boundary).
    """

    def __init__(self, *args):
        """Initialise a Polyline2d from a list of nodes.

        Args:
            *args: Exactly one positional argument — a ``list`` of ``Node2d``
                instances.

        Raises:
            TypeError: If the argument count is wrong, the argument is not a
                list, or any list element is not a ``Node2d``.
        """
        if len(args) == 0:
            raise TypeError("Only one argument !!!")
        elif len(args) == 1:
            lst = args[0]
            if isinstance(lst, list):
                for i in lst:
                    if not isinstance(i, Node2d):
                        raise TypeError("List must be [Node2D] formed !!!")
                self.__node = lst
            else:
                raise TypeError("First arg must be type List !!!")
        elif len(args) > 1:
            raise TypeError("Only one argument !!!")

    def __str__(self):
        dispstr = "Polyline2d: size is %.0f \n" % len(self.__node)
        for n in self.__node:
            dispstr = (
                dispstr
                + "Node2D: idn = {:.0f}, x = {:.3e}, y = {:.3e} \n".format(
                    n.idn,
                    n.x,
                    n.y,
                )
            )
        return dispstr

    def getNodes(self) -> list:
        """Return the underlying list of nodes.

        Returns:
            list[Node2d]: The node list (not a copy).
        """
        return self.__node

    def size(self) -> int:
        """Return the number of nodes in the polyline.

        Returns:
            int: Node count.
        """
        return len(self.__node)

    def setClosed(self) -> None:
        """Close the polyline by appending the first node at the end."""
        self.__node.append(self.__node[0])
        return

    def isClosed(self) -> bool:
        """Return ``True`` if the first and last nodes are the same.

        Returns:
            bool: ``True`` when the polyline is closed.
        """
        sz = len(self.__node)
        if self.__node[0] == self.__node[sz - 1]:
            return True
        else:
            return False

    def translate(
        self, pStart: Union[Point2d, None], pEnd: Union[Point2d, None]
    ) -> None:
        """Translate all nodes by the vector ``pEnd - pStart``.

        Args:
            pStart: Origin of the translation vector.  Defaults to the
                coordinate origin when ``None``.
            pEnd: Tip of the translation vector.  Defaults to the coordinate
                origin when ``None``.
        """
        if pStart is None:
            pStart = Point2d()
        if pEnd is None:
            pEnd = Point2d()

        v = pEnd - pStart
        for node in self.__node:
            node.translate(v.x, v.y)

    def vertexNb(self) -> int:
        """Return the number of vertices in the polyline.

        Returns:
            int: Vertex count (alias for :meth:`size`).
        """
        return len(self.__node)

    def vertexAt(self, i):
        """Return the number of sub-vertices at position *i*.

        Args:
            i: Index into the node list.

        Returns:
            int: Length of the node at position *i* (typically always 1 for
                plain ``Node2d`` objects).
        """
        return len(self.__node[i])


class Polyline3d:
    """List of Nodes3D

    An ordered sequence of ``Node3d`` vertices representing an open or closed
    3-D polyline.
    """

    def __init__(self, *args):
        """Initialise a Polyline3d from a list of nodes.

        Args:
            *args: Exactly one positional argument — a ``list`` of ``Node3d``
                instances.

        Raises:
            Exception: If the argument count is wrong, the argument is not a
                list, or any list element is not a ``Node3d``.
        """
        if len(args) == 0:
            raise Exception("Only one argument !!!")
        elif len(args) == 1:
            lst = args[0]
            if isinstance(lst, list):
                for i in lst:
                    if not isinstance(i, Node3d):
                        raise Exception("List must be [Node3D] formed !!!")
                self.__node = lst
            else:
                raise Exception("First arg must be type List !!!")
        elif len(args) > 1:
            raise Exception("Only one argument !!!")

    def __str__(self):
        dispstr = "Polyline3d: size is %.0f \n" % len(self.__node)
        for n in self.__node:
            dispstr = (
                dispstr
                + "Node3D: idn = {:.0f}, x = {:.3e}, y = {:.3e}, z = {:.3e}\n".format(
                    n.idn,
                    n.x,
                    n.y,
                    n.z,
                )
            )
        return dispstr

    def setClosed(self) -> None:
        """Close the polyline by appending the first node at the end."""
        self.__node.append(self.__node[0])
        return

    def isClosed(self) -> bool:
        """Return ``True`` if the first and last nodes are the same.

        Returns:
            bool: ``True`` when the polyline is closed.
        """
        sz = len(self.__node)
        if self.__node[0] == self.__node[sz - 1]:
            return True
        else:
            return False

    def vertexNb(self) -> int:
        """Return the number of vertices in the polyline.

        Returns:
            int: Vertex count.
        """
        return len(self.__node)

    def vertexAt(self, i):
        """Return the number of sub-vertices at position *i*.

        Args:
            i: Index into the node list.

        Returns:
            int: Length of the node at position *i*.
        """
        return len(self.__node[i])


# Defines points or free vector in 3D space
class Vector2d:
    """A free vector in 2-D space.

    Can be constructed either from two ``Point2d`` endpoints or by providing
    the components directly.

    Attributes:
        vx: X component.
        vy: Y component.
    """

    def __init__(
        self,
        p0: Point2d = Point2d(make_null=True),
        p1: Point2d = Point2d(make_null=True),
        vx: float = 0.0,
        vy: float = 0.0,
    ):
        """Initialise a Vector2d.

        When *p0* and *p1* are non-null ``Point2d`` instances the vector is
        set to ``p1 - p0``.  Otherwise *vx* / *vy* are used directly.

        Args:
            p0: Start point (optional).
            p1: End point (optional).
            vx: X component used when *p0* / *p1* are null.
            vy: Y component used when *p0* / *p1* are null.

        Raises:
            TypeError: If *p0* or *p1* are not ``Point2d`` instances.
        """
        if isinstance(p0, Point2d) and isinstance(p1, Point2d):
            if not p0.isNull() and not p1.isNull():
                self.vx = p1.x - p0.x
                self.vy = p1.y - p0.y
                return
        else:
            raise TypeError("p0 and p1 must be Point2d instances")

        self.vx = vx
        self.vy = vy

    def __str__(self):
        return f"Vector2D: vx = {self.vx:.3e}, vy = {self.vy:.3e}"

    def __add__(self, other: Vector2d) -> Vector2d:
        return Vector2d(vx=self.vx + other.vx, vy=self.vy + other.vy)

    def __sub__(self, other: Vector2d) -> Vector2d:
        if not isinstance(other, Vector2d):
            return NotImplemented
        return Vector2d(vx=self.vx - other.vx, vy=self.vy - other.vy)

    # def __mul__(self, scale):
    #    return Vector3d(self.vx*scale,self.vy*scale,self.z*scale)

    def __rmul__(self, scale):
        return Vector2d(vx=self.vx * scale, vy=self.vy * scale)

    #       _    _    _            _         _         _
    #     | x    y    z  |   y1*z2*x + z1*x2*y + x1*y2*z +
    #  det| x1   y1   z1 | =       _         _         _
    #     | x2   y2   z2 |  -y2*z1*x - x1*z2*y - x2*y1*z
    #
    def cross(self, other: Vector2d) -> float:
        """Compute the scalar cross product (z-component of 3-D cross).

        Args:
            other: The second ``Vector2d``.

        Returns:
            float: ``vx * other.vy - other.vx * vy``.
        """
        x1 = self.vx
        y1 = self.vy

        x2 = other.vx
        y2 = other.vy

        return x1 * y2 - x2 * y1

    def norm(self) -> float:
        """Return the Euclidean norm of the vector.

        Returns:
            float: ``sqrt(vx² + vy²)``.
        """
        return math.sqrt(math.pow(self.vx, 2) + math.pow(self.vy, 2))

    def norm_roxroy(self, rox=1.0, roy=1.0) -> float:
        """Return the norm scaled by reference lengths along each axis.

        Args:
            rox: Reference length for the X component.
            roy: Reference length for the Y component.

        Returns:
            float: ``sqrt((vx/rox)² + (vy/roy)²)``.
        """
        return math.sqrt(math.pow(self.vx / rox, 2) + math.pow(self.vy / roy, 2))

    def normalize(self) -> Vector2d:
        """Normalise the vector in-place to unit length.

        Returns:
            Vector2d: *self* after normalisation (allows chaining).

        Raises:
            Exception: If the vector is the zero vector.
        """
        n = self.norm()
        if n != 0:
            self.vx = self.vx / n
            self.vy = self.vy / n
        else:
            raise Exception("Vector 2D null !!!")
        return self

    def rotate(self, angle: float) -> Vector2d:
        """Rotate the vector in-place around the origin.

        Args:
            angle: Rotation angle in **degrees** (positive = counter-clockwise).

        Returns:
            Vector2d: *self* after rotation (allows chaining).
        """
        hold_vx = self.vx
        hold_vy = self.vy
        alpha = math.radians(angle)
        self.vx = (math.cos(alpha)) * hold_vx + (-math.sin(alpha)) * hold_vy
        self.vy = (math.cos(alpha)) * hold_vy + (math.sin(alpha)) * hold_vx

        return self


# Defines points or free vector in 3D space
class Vector3d:
    """A free vector in 3-D space.

    Supports multiple construction signatures:

    * No arguments — zero vector.
    * Three scalars — ``Vector3d(vx, vy, vz)``.
    * Two ``Point3d`` instances — ``Vector3d(p0, p1)`` → ``p1 - p0``.
    * One 3-tuple of scalars — ``Vector3d((vx, vy, vz))``.

    Attributes:
        vx: X component.
        vy: Y component.
        vz: Z component.
    """

    def __init__(self, *args):
        """Initialise a Vector3d.

        Args:
            *args: One of the supported signatures described in the class
                docstring.

        Raises:
            Exception: On unsupported argument types or counts.
        """
        if len(args) == 3:
            for a in args:
                if isinstance(a, float) is False and isinstance(a, int) is False:
                    raise Exception("With len args == 3 only float or int type !!!")

            self.vx = float(args[0])
            self.vy = float(args[1])
            self.vz = float(args[2])

        elif len(args) == 2:
            for a in args:
                if not isinstance(a, Point3d):
                    raise Exception("With len args == 2 only Point3d type !!!")

            self.vx = args[1].x - args[0].x
            self.vy = args[1].y - args[0].y
            self.vz = args[1].z - args[0].z

        elif len(args) == 1:
            if not isinstance(args[0], tuple):
                raise Exception("With 1 arg only a tuple !!!")

            if len(args[0]) != 3:
                raise Exception("Tuple must have len == 3 !!!", len(args[0]))

            arg0 = not(not isinstance(args[0][0], float) and not isinstance(args[0][0], int))
            arg1 = not(not isinstance(args[0][1], float) and not isinstance(args[0][1], int))
            arg2 = not(not isinstance(args[0][2], float) and not isinstance(args[0][2], int))

            if not arg0 * arg1 * arg2:
                raise Exception("Tuple must be (float | int, float | int, float | int)!!!")

            self.vx = args[0][0]
            self.vy = args[0][1]
            self.vz = args[0][2]

        elif len(args) == 0:
            self.vx = 0.0
            self.vy = 0.0
            self.vz = 0.0

        else:
            raise Exception("Wrong args !!!", len(args))

    def __str__(self):
        return f"Vector3D: vx = {self.vx:.3e}, vy = {self.vy:.3e}, vz = {self.vz:.3e}"

    def __add__(self, other: Vector3d) -> Vector3d:
        return Vector3d(self.vx + other.vx, self.vy + other.vy, self.vz + other.vz)

    def __sub__(self, other: Vector3d) -> Vector3d:
        return Vector3d(self.vx - other.vx, self.vy - other.vy, self.vz - other.vz)

    # def __mul__(self, scale):
    #    return Vector3d(self.vx*scale,self.vy*scale,self.z*scale)

    def __rmul__(self, scale):
        return Vector3d(self.vx * scale, self.vy * scale, self.vz * scale)

    #       _    _    _            _         _         _
    #     | x    y    z  |   y1*z2*x + z1*x2*y + x1*y2*z +
    #  det| x1   y1   z1 | =       _         _         _
    #     | x2   y2   z2 |  -y2*z1*x - x1*z2*y - x2*y1*z
    #
    def cross(self, other) -> Vector3d:
        """Return the 3-D cross product ``self × other``.

        Args:
            other: The second ``Vector3d``.

        Returns:
            Vector3d: A new vector orthogonal to both operands.
        """
        x1 = self.vx
        y1 = self.vy
        z1 = self.vz
        x2 = other.vx
        y2 = other.vy
        z2 = other.vz
        x3 = y1 * z2 - y2 * z1
        y3 = z1 * x2 - x1 * z2
        z3 = x1 * y2 - x2 * y1
        return Vector3d(x3, y3, z3)

    def norm(self) -> float:
        """Return the Euclidean norm of the vector.

        Returns:
            float: ``sqrt(vx² + vy² + vz²)``.
        """
        return math.sqrt(
            math.pow(self.vx, 2) + math.pow(self.vy, 2) + math.pow(self.vz, 2)
        )

    def normalize(self) -> Vector3d:
        """Normalise the vector in-place to unit length.

        Returns:
            Vector3d: *self* after normalisation (allows chaining).

        Raises:
            Exception: If the vector is the zero vector.
        """
        n = self.norm()
        if n != 0:
            self.vx = self.vx / n
            self.vy = self.vy / n
            self.vz = self.vz / n
        else:
            raise Exception("Vector 3D null !!!")
        return self

    def scalar(self, other) -> float:
        """Return the dot product of this vector with *other*.

        Args:
            other: The second ``Vector3d``.

        Returns:
            float: ``vx*other.vx + vy*other.vy + vz*other.vz``.
        """
        return self.vx * other.vx + self.vy * other.vy + self.vz * other.vz

    def __eq__(self, other: Vector3d) -> bool:
        if not isinstance(other, Vector3d):
            return NotImplemented

        if self.vx == other.vx and self.vy == other.vy and self.vz == other.vz:
            return True

        return False

# Sum vector to point in affine space
def affineSum2d(p: Point2d, v: Vector2d) -> Point2d:
    """Return the point obtained by adding a 2-D vector to a point.

    Args:
        p: Base point.
        v: Displacement vector.

    Returns:
        Point2d: ``p + v``.
    """
    return Point2d(p.x + v.vx, p.y + v.vy)


# Make a points array with equal space between theme
# Extremes stand for include first and last point
def twoPointsDivide(
    p0: Point2d, p1: Point2d, nb: int, extremes: bool = True
) -> List[Point2d]:
    """Divide the segment ``p0 → p1`` into *nb* equal parts.

    Args:
        p0: Start point.
        p1: End point.
        nb: Number of equal sub-divisions (must be ≥ 1).
        extremes: When ``True`` (default), *p0* and *p1* are included as the
            first and last points of the result.

    Returns:
        List[Point2d]: Points along the segment at equal spacing.

    Raises:
        ValueError: If *nb* < 1.
    """
    if nb < 1:
        raise ValueError("nb must be >=1")
    lPoints = []
    if extremes:
        lPoints.append(deepcopy(p0))
    vDir = Vector2d(p0, p1).normalize()
    space = p0.distance(p1) / nb
    for i in range(1, nb):
        lPoints.append(affineSum2d(p0, space * i * vDir))
    if extremes:
        lPoints.append(deepcopy(p1))
    return lPoints


def twoPointsMiddle(p0: Point2d, p1: Point2d) -> Point2d:
    """Return the midpoint between two 2-D points.

    Args:
        p0: First point.
        p1: Second point.

    Returns:
        Point2d: The midpoint ``(p0 + p1) / 2``.
    """
    return Point2d((p0.x + p1.x) / 2, (p0.y + p1.y) / 2)


# Copy of points translated with offset
def twoPointsOffset(
    p0: Point2d, p1: Point2d, offset: float = 0.0
) -> Tuple[Point2d, Point2d]:
    """Return copies of *p0* and *p1* offset perpendicularly by *offset*.

    The normal direction is obtained by rotating the segment direction 90°
    counter-clockwise.

    Args:
        p0: Start point of the reference segment.
        p1: End point of the reference segment.
        offset: Lateral offset distance (positive = left of ``p0→p1``).

    Returns:
        Tuple[Point2d, Point2d]: The offset copies ``(p0', p1')``.
    """
    nDir = offset * Vector2d(p0, p1).normalize().rotate(90)
    return affineSum2d(p0, nDir), affineSum2d(p1, nDir)


class Point2dMdl(BaseModel):
    """Pydantic model wrapping an optional 2-D point coordinate pair.

    Attributes:
        xy: Optional ``(x, y)`` coordinate tuple.
    """

    xy: Optional[Tuple[float, float]] = None


class Vector3dApp(Vector3d):
    """A 3-D vector with an explicit application point (origin).

    Extends ``Vector3d`` by storing the point in space where the vector is
    applied, which is required for moment transport and force-couple
    operations.

    Attributes:
        o: Application point (``Point3d``).
    """

    def __init__(self, origin: Point3d, *args: float | int | tuple[float|int, float|int, float|int]):
        """Initialise a Vector3dApp.

        Args:
            origin: The application point of the vector.
            *args: Vector components forwarded to the ``Vector3d`` constructor
                (three scalars, a 3-tuple, or nothing for the zero vector).
        """
        super().__init__(*args)
        self.__origin = origin

    @property
    def o(self) -> Point3d:
        """Point3d: Application point of the vector."""
        return deepcopy(self.__origin)

    @o.setter
    def o(self, val: Point3d) -> None:
        self.__origin = deepcopy(val)

    def __add__(self, other: Vector3d) -> "Vector3dApp":
        return Vector3dApp(self.__origin, self.vx + other.vx, self.vy + other.vy, self.vz + other.vz)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector3dApp):
            return NotImplemented

        c1 = self.vx == other.vx
        c2 = self.vy == other.vy
        c3 = self.vz == other.vz
        c4 = self.__origin.x == other.o.x
        c5 = self.__origin.y == other.o.y
        c6 = self.__origin.z == other.o.z

        if all([c1, c2, c3, c4, c5, c6]):
            return True
        else:
            return False

class ForceCouple:
    """A force-couple system (wrench) consisting of a resultant force and a moment.

    Stores a force applied at a specific point together with a free couple
    vector.  The couple is automatically updated when the application point is
    changed via the :attr:`o` setter (moment transport formula).

    Attributes:
        force: Applied force as a ``Vector3dApp``.
        couple: Free moment vector as a ``Vector3d``.
    """

    def __init__(self, force: Vector3dApp, couple: Vector3d):
        """Initialise a ForceCouple.

        Deep copies are taken of both arguments to prevent external mutation.

        Args:
            force: The applied force (carries its application point).
            couple: The free moment vector.
        """
        self.__force: Vector3dApp = deepcopy(force)
        self.__couple: Vector3d = deepcopy(couple)

    @property
    def force(self) -> Vector3dApp:
        """Vector3dApp: Deep copy of the applied force."""
        return deepcopy(self.__force)

    @force.setter
    def force(self, val: Vector3dApp) -> None:
        self.__force = val

    @property
    def couple(self) -> Vector3d:
        """Vector3d: Deep copy of the free moment vector."""
        return deepcopy(self.__couple)

    @couple.setter
    def couple(self, val: Vector3d) -> None:
        self.__couple = val

    @property
    def o(self) -> Point3d:
        """Point3d: Application point of the force (read from the force vector)."""
        return self.__force.o

    @o.setter
    def o(self, new_o: Point3d) -> None:
        """Transport the force-couple to a new application point.

        Updates the couple via the moment transport formula:
        ``M' = r × F + M``
        where *r* is the vector from *new_o* to the current application point.

        Args:
            new_o: New application point.

        Raises:
            ValueError: If *new_o* is not a ``Point3d``.
        """
        if not isinstance(new_o, Point3d):
            raise ValueError("new_o must be a Point3d !!!", type(new_o))

        old_o = self.__force.o
        v_new_old = Vector3d(
            old_o.x - new_o.x,
            old_o.y - new_o.y,
            old_o.z - new_o.z,
        )
        self.__couple = v_new_old.cross(self.__force) + self.__couple
        self.__force.o = new_o

    def __add__(self, other: ForceCouple) -> ForceCouple:
        """Return the sum of two force-couples reduced to the same point.

        The *other* force-couple is transported to *self*'s application point
        before summing.

        Args:
            other: The second ``ForceCouple``.

        Returns:
            ForceCouple: Resultant force-couple at *self*'s application point.

        Raises:
            ValueError: If *other* is not a ``ForceCouple``.
        """
        if not isinstance(other, ForceCouple):
            raise ValueError("new_o must be a ForceCouple !!!", type(other))
        self.__force += other.force
        other.o = self.o
        self.__couple += other.couple
        return ForceCouple(self.__force, self.__couple)


class ForceCoupleSystem:
    """A system of multiple ``ForceCouple`` objects.

    Provides utilities to compute the barycentre of application points and to
    reduce the system to a single equivalent force-couple at a given point.

    Note:
        This class is currently a stub — methods are not yet implemented.
    """

    def __init__(self, forces: List[ForceCouple]):
        """Initialise a ForceCoupleSystem.

        Args:
            forces: List of ``ForceCouple`` objects forming the system.
        """
        self.__fc = forces

    def baryOfApplicationPoints(self) -> Point3d:
        """Return the barycentre of all force application points.

        Returns:
            Point3d: The barycentre point.
        """
        nb_points = len(self.__fc)
        xg = sum([f.o.x for f in self.__fc]) / nb_points
        yg = sum([f.o.y for f in self.__fc]) / nb_points
        zg = sum([f.o.z for f in self.__fc]) / nb_points

        return Point3d(xg, yg, zg)


    def forceCoupleResume(self, applicationPoint: Point3d) -> ForceCouple:
        """Reduce the entire system to a single force-couple at *applicationPoint*.

        Args:
            applicationPoint: The reference point for moment transport.

        Returns:
            ForceCouple: Equivalent resultant force-couple.
        """
        forces = deepcopy(self.__fc)
        resumed = forces[0]
        resumed.o = applicationPoint
        for i in range(1, len(forces)):
            resumed += forces[i]

        return resumed