use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
use crate::{Direction, Direction2, Matrix3, Point, Point2, Vector, Vector2};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Handedness {
#[default]
Right,
Left,
}
impl Handedness {
#[must_use]
pub const fn sign(self) -> f64 {
match self {
Self::Right => 1.0,
Self::Left => -1.0,
}
}
#[must_use]
pub const fn flipped(self) -> Self {
match self {
Self::Right => Self::Left,
Self::Left => Self::Right,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Axis {
pub location: Point,
pub direction: Direction,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Axis2 {
pub location: Point2,
pub direction: Direction2,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Frame {
origin: Point,
z: Direction,
x: Direction,
y: Direction,
handedness: Handedness,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Frame2 {
origin: Point2,
x: Direction2,
y: Direction2,
handedness: Handedness,
}
impl Axis {
pub const X: Self = Self {
location: Point::ORIGIN,
direction: Direction::X,
};
pub const Y: Self = Self {
location: Point::ORIGIN,
direction: Direction::Y,
};
pub const Z: Self = Self {
location: Point::ORIGIN,
direction: Direction::Z,
};
#[must_use]
pub const fn new(location: Point, direction: Direction) -> Self {
Self {
location,
direction,
}
}
pub fn through(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
Ok(Self::new(from, Direction::new(to - from, tol)?))
}
#[must_use]
pub const fn reversed(self) -> Self {
Self::new(self.location, self.direction.reversed())
}
#[must_use]
pub fn point_at(self, t: f64) -> Point {
self.location + self.direction * t
}
#[must_use]
pub fn parameter_of(self, p: Point) -> f64 {
self.direction.dot_vector(p - self.location)
}
#[must_use]
pub fn project(self, p: Point) -> Point {
self.point_at(self.parameter_of(p))
}
#[must_use]
pub fn distance_to(self, p: Point) -> f64 {
self.direction.cross_with(p - self.location).magnitude()
}
#[must_use]
pub fn contains(self, p: Point, tol: Tolerances) -> bool {
self.distance_to(p) <= tol.confusion()
}
#[must_use]
pub fn is_coaxial(self, other: Self, tol: Tolerances) -> bool {
self.direction.is_equal(other.direction, tol)
&& self.contains(other.location, tol)
&& other.contains(self.location, tol)
}
#[must_use]
pub fn is_collinear(self, other: Self, tol: Tolerances) -> bool {
self.direction.is_parallel(other.direction, tol)
&& self.contains(other.location, tol)
&& other.contains(self.location, tol)
}
}
impl Axis2 {
pub const X: Self = Self {
location: Point2::ORIGIN,
direction: Direction2::X,
};
pub const Y: Self = Self {
location: Point2::ORIGIN,
direction: Direction2::Y,
};
#[must_use]
pub const fn new(location: Point2, direction: Direction2) -> Self {
Self {
location,
direction,
}
}
pub fn through(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
Ok(Self::new(from, Direction2::new(to - from, tol)?))
}
#[must_use]
pub const fn reversed(self) -> Self {
Self::new(self.location, self.direction.reversed())
}
#[must_use]
pub fn point_at(self, t: f64) -> Point2 {
self.location + self.direction * t
}
#[must_use]
pub fn parameter_of(self, p: Point2) -> f64 {
self.direction.vector().dot(p - self.location)
}
#[must_use]
pub fn project(self, p: Point2) -> Point2 {
self.point_at(self.parameter_of(p))
}
#[must_use]
pub fn signed_distance_to(self, p: Point2) -> f64 {
self.direction.vector().cross(p - self.location)
}
#[must_use]
pub fn distance_to(self, p: Point2) -> f64 {
self.signed_distance_to(p).abs()
}
}
impl Default for Frame {
fn default() -> Self {
Self::WORLD
}
}
impl Frame {
pub const WORLD: Self = Self {
origin: Point::ORIGIN,
z: Direction::Z,
x: Direction::X,
y: Direction::Y,
handedness: Handedness::Right,
};
pub fn new(
origin: Point,
z: Direction,
x_reference: Direction,
tol: Tolerances,
) -> OgeomResult<Self> {
let v = x_reference.vector() - z.vector() * z.dot(x_reference);
let Ok(x) = Direction::new(v, tol) else {
ogeom_bail!(
Construction,
"frame reference direction is parallel to the primary direction"
);
};
let y = Direction::new(z.cross_vector(x), tol)?;
Ok(Self {
origin,
z,
x,
y,
handedness: Handedness::Right,
})
}
#[must_use]
pub fn about(origin: Point, z: Direction) -> Self {
let x = z.any_perpendicular();
let y =
Direction::new(z.cross_vector(x), Tolerances::millimetres()).unwrap_or(Direction::Y);
Self {
origin,
z,
x,
y,
handedness: Handedness::Right,
}
}
pub fn from_axes(
origin: Point,
x: Direction,
y: Direction,
z: Direction,
tol: Tolerances,
) -> OgeomResult<Self> {
for (a, b, names) in [(x, y, "x/y"), (y, z, "y/z"), (z, x, "z/x")] {
if !a.is_normal(b, tol) {
ogeom_bail!(Construction, "frame axes {names} are not perpendicular");
}
}
let triple = x.vector().triple(y.vector(), z.vector());
if triple.abs() <= tol.angular() {
ogeom_bail!(Construction, "frame axes are coplanar");
}
let handedness = if triple > 0.0 {
Handedness::Right
} else {
Handedness::Left
};
Ok(Self {
origin,
z,
x,
y,
handedness,
})
}
#[must_use]
pub const fn origin(&self) -> Point {
self.origin
}
#[must_use]
pub const fn z(&self) -> Direction {
self.z
}
#[must_use]
pub const fn x(&self) -> Direction {
self.x
}
#[must_use]
pub const fn y(&self) -> Direction {
self.y
}
#[must_use]
pub const fn handedness(&self) -> Handedness {
self.handedness
}
#[must_use]
pub const fn axis(&self) -> Axis {
Axis::new(self.origin, self.z)
}
#[must_use]
pub const fn with_origin(&self, origin: Point) -> Self {
Self { origin, ..*self }
}
#[must_use]
pub const fn mirrored(&self) -> Self {
Self {
y: self.y.reversed(),
handedness: self.handedness.flipped(),
..*self
}
}
#[must_use]
pub const fn with_z_reversed(&self) -> Self {
Self {
z: self.z.reversed(),
y: self.y.reversed(),
..*self
}
}
#[must_use]
pub fn to_local(&self, p: Point) -> Point {
let v = p - self.origin;
Point::new(
self.x.dot_vector(v),
self.y.dot_vector(v),
self.z.dot_vector(v),
)
}
#[must_use]
pub fn to_world(&self, p: Point) -> Point {
self.origin + self.x * p.x + self.y * p.y + self.z * p.z
}
#[must_use]
pub fn vector_to_local(&self, v: Vector) -> Vector {
Vector::new(
self.x.dot_vector(v),
self.y.dot_vector(v),
self.z.dot_vector(v),
)
}
#[must_use]
pub fn vector_to_world(&self, v: Vector) -> Vector {
self.x * v.x + self.y * v.y + self.z * v.z
}
#[must_use]
pub fn to_matrix(&self) -> Matrix3 {
Matrix3::from_columns(self.x.vector(), self.y.vector(), self.z.vector())
}
#[must_use]
pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
self.origin.is_equal(other.origin, tol)
&& self.x.is_equal(other.x, tol)
&& self.y.is_equal(other.y, tol)
&& self.z.is_equal(other.z, tol)
}
#[must_use]
pub fn signed_distance_to_plane(&self, p: Point) -> f64 {
self.z.dot_vector(p - self.origin)
}
}
impl Default for Frame2 {
fn default() -> Self {
Self::WORLD
}
}
impl Frame2 {
pub const WORLD: Self = Self {
origin: Point2::ORIGIN,
x: Direction2::X,
y: Direction2::Y,
handedness: Handedness::Right,
};
#[must_use]
pub const fn new(origin: Point2, x: Direction2) -> Self {
Self {
origin,
x,
y: x.perpendicular(),
handedness: Handedness::Right,
}
}
pub fn from_axes(
origin: Point2,
x: Direction2,
y: Direction2,
tol: Tolerances,
) -> OgeomResult<Self> {
if !x.is_normal(y, tol) {
ogeom_bail!(Construction, "frame axes are not perpendicular");
}
let handedness = if x.cross(y) > 0.0 {
Handedness::Right
} else {
Handedness::Left
};
Ok(Self {
origin,
x,
y,
handedness,
})
}
#[must_use]
pub const fn origin(&self) -> Point2 {
self.origin
}
#[must_use]
pub const fn x(&self) -> Direction2 {
self.x
}
#[must_use]
pub const fn y(&self) -> Direction2 {
self.y
}
#[must_use]
pub const fn handedness(&self) -> Handedness {
self.handedness
}
#[must_use]
pub const fn mirrored(&self) -> Self {
Self {
y: self.y.reversed(),
handedness: self.handedness.flipped(),
..*self
}
}
#[must_use]
pub fn to_local(&self, p: Point2) -> Point2 {
let v = p - self.origin;
Point2::new(self.x.vector().dot(v), self.y.vector().dot(v))
}
#[must_use]
pub fn to_world(&self, p: Point2) -> Point2 {
self.origin + self.x * p.x + self.y * p.y
}
#[must_use]
pub fn vector_to_local(&self, v: Vector2) -> Vector2 {
Vector2::new(self.x.vector().dot(v), self.y.vector().dot(v))
}
#[must_use]
pub fn vector_to_world(&self, v: Vector2) -> Vector2 {
self.x * v.x + self.y * v.y
}
#[must_use]
pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
self.origin.is_equal(other.origin, tol)
&& self.x.is_equal(other.x, tol)
&& self.y.is_equal(other.y, tol)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use approx::assert_relative_eq;
const T: Tolerances = Tolerances::millimetres();
#[test]
fn axis_projection_and_distance() {
let a = Axis::new(Point::new(1.0, 0.0, 0.0), Direction::Z);
let p = Point::new(4.0, 0.0, 7.0);
assert_relative_eq!(a.parameter_of(p), 7.0);
assert_eq!(a.project(p), Point::new(1.0, 0.0, 7.0));
assert_relative_eq!(a.distance_to(p), 3.0);
assert!(a.contains(Point::new(1.0, 0.0, -5.0), T));
assert!(!a.contains(p, T));
}
#[test]
fn axis_distance_stays_accurate_far_along_the_axis() {
let a = Axis::Z;
let p = Point::new(3.0, 0.0, 1.0e9);
assert_relative_eq!(a.distance_to(p), 3.0, epsilon = 1e-9);
}
#[test]
fn axis_through_coincident_points_is_refused() {
let p = Point::new(1.0, 2.0, 3.0);
assert!(Axis::through(p, p, T).is_err());
assert!(Axis::through(p, Point::new(1.0, 2.0, 4.0), T).is_ok());
}
#[test]
fn coaxial_and_collinear_differ_by_sense() {
let a = Axis::Z;
let b = Axis::new(Point::new(0.0, 0.0, 5.0), Direction::Z);
let c = b.reversed();
assert!(a.is_coaxial(b, T));
assert!(!a.is_coaxial(c, T), "opposite sense is not coaxial");
assert!(a.is_collinear(c, T), "but it is collinear");
assert!(!a.is_collinear(Axis::X, T));
}
#[test]
fn frame_orthonormalizes_a_non_perpendicular_reference() {
let reference = Direction::from_coords(1.0, 0.0, 10.0, T).unwrap();
let f = Frame::new(Point::ORIGIN, Direction::Z, reference, T).unwrap();
assert!(f.x().is_equal(Direction::X, T));
assert!(f.y().is_equal(Direction::Y, T));
assert!(f.to_matrix().is_orthonormal(1e-14));
}
#[test]
fn frame_refuses_a_parallel_reference() {
assert!(Frame::new(Point::ORIGIN, Direction::Z, Direction::Z, T).is_err());
assert!(Frame::new(Point::ORIGIN, Direction::Z, -Direction::Z, T).is_err());
}
#[test]
fn frame_about_works_for_every_primary_direction() {
for z in [
Direction::X,
Direction::Y,
Direction::Z,
-Direction::Y,
Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
] {
let f = Frame::about(Point::new(1.0, 2.0, 3.0), z);
assert!(f.z().is_equal(z, T));
assert!(f.to_matrix().is_orthonormal(1e-14));
assert_eq!(f.handedness(), Handedness::Right);
}
}
#[test]
fn local_and_world_coordinates_round_trip() {
let f = Frame::new(
Point::new(10.0, -5.0, 2.0),
Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
Direction::X,
T,
)
.unwrap();
for p in [
Point::ORIGIN,
Point::new(1.0, 2.0, 3.0),
Point::new(-100.0, 0.5, 7.0),
] {
assert!(f.to_world(f.to_local(p)).is_equal(p, T));
}
assert!(f.to_local(f.origin()).is_equal(Point::ORIGIN, T));
assert!(
f.to_local(f.origin() + f.x() * 1.0)
.is_equal(Point::new(1.0, 0.0, 0.0), T)
);
}
#[test]
fn vectors_ignore_the_origin_but_points_do_not() {
let f = Frame::new(Point::new(100.0, 0.0, 0.0), Direction::Z, Direction::X, T).unwrap();
let v = Vector::new(1.0, 2.0, 3.0);
assert!(
f.vector_to_local(v).is_equal(v, T),
"aligned frame, offset origin"
);
assert!(
!f.to_local(Point::from_vector(v))
.is_equal(Point::from_vector(v), T)
);
}
#[test]
fn handedness_is_inferred_not_asserted() {
let right =
Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, Direction::Z, T).unwrap();
assert_eq!(right.handedness(), Handedness::Right);
let left =
Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, -Direction::Z, T).unwrap();
assert_eq!(left.handedness(), Handedness::Left);
assert_relative_eq!(left.handedness().sign(), -1.0);
}
#[test]
fn from_axes_rejects_non_orthogonal_and_coplanar_input() {
let skew = Direction::from_coords(1.0, 1.0, 0.0, T).unwrap();
assert!(Frame::from_axes(Point::ORIGIN, Direction::X, skew, Direction::Z, T).is_err());
assert!(
Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, Direction::X, T).is_err()
);
}
#[test]
fn reversing_the_primary_direction_preserves_handedness() {
let f = Frame::WORLD;
let r = f.with_z_reversed();
assert!(r.z().is_equal(-Direction::Z, T));
assert!(r.x().is_equal(Direction::X, T), "x is kept");
assert!(r.y().is_equal(-Direction::Y, T), "y flips to compensate");
assert_eq!(r.handedness(), Handedness::Right);
assert_relative_eq!(
r.x().vector().triple(r.y().vector(), r.z().vector()),
1.0,
epsilon = 1e-15
);
}
#[test]
fn mirroring_flips_handedness() {
let m = Frame::WORLD.mirrored();
assert_eq!(m.handedness(), Handedness::Left);
assert_eq!(m.mirrored().handedness(), Handedness::Right);
assert_relative_eq!(
m.x().vector().triple(m.y().vector(), m.z().vector()),
-1.0,
epsilon = 1e-15
);
}
#[test]
fn signed_distance_to_the_frame_plane() {
let f = Frame::WORLD;
assert_relative_eq!(f.signed_distance_to_plane(Point::new(1.0, 2.0, 3.0)), 3.0);
assert_relative_eq!(f.signed_distance_to_plane(Point::new(1.0, 2.0, -3.0)), -3.0);
assert_relative_eq!(
f.with_z_reversed()
.signed_distance_to_plane(Point::new(0.0, 0.0, 3.0)),
-3.0
);
}
#[test]
fn frame2_round_trips_and_infers_handedness() {
let f = Frame2::new(Point2::new(3.0, 4.0), Direction2::from_angle(0.6));
assert_eq!(f.handedness(), Handedness::Right);
for p in [Point2::ORIGIN, Point2::new(-2.0, 7.0)] {
assert!(f.to_world(f.to_local(p)).is_equal(p, T));
}
let left = Frame2::from_axes(Point2::ORIGIN, Direction2::X, -Direction2::Y, T).unwrap();
assert_eq!(left.handedness(), Handedness::Left);
assert!(Frame2::from_axes(Point2::ORIGIN, Direction2::X, Direction2::X, T).is_err());
}
#[test]
fn axis2_signed_distance_is_positive_on_the_left() {
let a = Axis2::X;
assert_relative_eq!(a.signed_distance_to(Point2::new(5.0, 2.0)), 2.0);
assert_relative_eq!(a.signed_distance_to(Point2::new(5.0, -2.0)), -2.0);
assert_relative_eq!(a.distance_to(Point2::new(5.0, -2.0)), 2.0);
assert_eq!(a.project(Point2::new(5.0, 2.0)), Point2::new(5.0, 0.0));
}
}