M0: workspace, geometry foundation, sketch constraint solver, kernel boundary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:01:29 +00:00
commit dafce1f4e2
47 files changed
+5624

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod mesh;
mod plane;
mod profile;
mod vec;
pub use mesh::{Aabb3, Mesh, MeshMeasure};
pub use plane::Plane;
pub use profile::{Loop2, Region2, Seg2};
pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests (mm).
pub const EPS: f64 = 1e-9;
/// Linear tolerance used for "same point" decisions in modelling (mm).
pub const LINEAR_TOL: f64 = 1e-6;
/// `x` if finite, else `fallback`.
pub fn finite_or(x: f64, fallback: f64) -> f64 {
if x.is_finite() { x } else { fallback }
}
#[cfg(test)]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use crate::Vec3;
/// Axis-aligned bounding box.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Aabb3 {
pub min: Vec3,
pub max: Vec3,
}
impl Default for Aabb3 {
fn default() -> Self {
Aabb3::EMPTY
}
}
impl Aabb3 {
pub const EMPTY: Aabb3 = Aabb3 {
min: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
max: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
};
pub fn is_empty(&self) -> bool {
!(self.min.x <= self.max.x && self.min.y <= self.max.y && self.min.z <= self.max.z)
}
pub fn add(&mut self, p: Vec3) {
if p.is_finite() {
self.min = self.min.min(p);
self.max = self.max.max(p);
}
}
pub fn union(&self, o: &Aabb3) -> Aabb3 {
if self.is_empty() {
return *o;
}
if o.is_empty() {
return *self;
}
Aabb3 { min: self.min.min(o.min), max: self.max.max(o.max) }
}
pub fn center(&self) -> Vec3 {
if self.is_empty() { Vec3::ZERO } else { (self.min + self.max) * 0.5 }
}
pub fn size(&self) -> Vec3 {
if self.is_empty() { Vec3::ZERO } else { self.max - self.min }
}
pub fn diagonal(&self) -> f64 {
self.size().len()
}
}
/// An indexed triangle mesh with per-vertex normals, a face id per triangle (the B-rep face it
/// came from) and edge polylines (the B-rep edges, for display and picking).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Mesh {
pub positions: Vec<Vec3>,
pub normals: Vec<Vec3>,
/// Triangles as vertex index triples (counter-clockwise seen from outside).
pub triangles: Vec<[u32; 3]>,
/// B-rep face index per triangle.
pub tri_face: Vec<u32>,
/// B-rep edges as polylines.
pub edges: Vec<Vec<Vec3>>,
}
/// Mass properties of a closed mesh.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshMeasure {
pub volume: f64,
pub area: f64,
pub centroid: Vec3,
}
impl Mesh {
pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> {
Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?])
}
pub fn bounds(&self) -> Aabb3 {
let mut b = Aabb3::EMPTY;
for p in &self.positions {
b.add(*p);
}
b
}
/// Signed volume (divergence theorem), surface area and volume centroid.
pub fn measure(&self) -> MeshMeasure {
let (mut v6, mut area, mut c) = (0.0, 0.0, Vec3::ZERO);
for t in &self.triangles {
let Some([a, b, cc]) = self.tri(t) else { continue };
let d = a.dot(b.cross(cc));
v6 += d;
c += (a + b + cc) * d;
area += (b - a).cross(cc - a).len() * 0.5;
}
let volume = v6 / 6.0;
let centroid = if v6.abs() > 1e-12 { c / (4.0 * v6) } else { self.bounds().center() };
MeshMeasure { volume, area, centroid }
}
/// Append another mesh (face ids offset by `face_offset`).
pub fn append(&mut self, o: &Mesh, face_offset: u32) {
let base = u32::try_from(self.positions.len()).unwrap_or(u32::MAX);
self.positions.extend_from_slice(&o.positions);
self.normals.extend_from_slice(&o.normals);
self.triangles.extend(o.triangles.iter().map(|t| [t[0].saturating_add(base), t[1].saturating_add(base), t[2].saturating_add(base)]));
self.tri_face.extend(o.tri_face.iter().map(|f| f.saturating_add(face_offset)));
self.edges.extend(o.edges.iter().cloned());
}
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
for (i, t) in self.triangles.iter().enumerate() {
let Some([a, b, c]) = self.tri(t) else { continue };
let (e1, e2) = (b - a, c - a);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-14 {
continue;
}
let inv = 1.0 / det;
let s = o - a;
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
continue;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
continue;
}
let t = e2.dot(q) * inv;
if t > 1e-9 && best.is_none_or(|(bt, _)| t < bt) {
best = Some((t, i));
}
}
best
}
}
+83
View File
@@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
use crate::{Vec2, Vec3};
/// A sketch plane: an origin and two orthonormal in-plane axes. The normal is `x × y`.
///
/// World point of sketch point `(u, v)` = `origin + u·x + v·y`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Plane {
pub origin: Vec3,
#[serde(rename = "x_dir")]
pub x: Vec3,
#[serde(rename = "y_dir")]
pub y: Vec3,
}
impl Plane {
/// Top: sketch x = +X, y = +Y, normal +Z.
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
/// Front: sketch x = +X, y = +Z, normal −Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// Right: sketch x = +Y, y = +Z, normal +X.
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
/// Plane through `origin` with the given axes, re-orthonormalised. `None` if degenerate.
pub fn new(origin: Vec3, x: Vec3, y: Vec3) -> Option<Plane> {
let x = x.normalized()?;
let n = x.cross(y).normalized()?;
let y = n.cross(x).normalized()?;
origin.is_finite().then_some(Plane { origin, x, y })
}
/// Plane through `origin` with normal `n` (in-plane axes chosen deterministically).
pub fn from_normal(origin: Vec3, n: Vec3) -> Option<Plane> {
let n = n.normalized()?;
// Horizontal planes keep world X as sketch x; others use the horizontal direction Z × n.
let cand = if n.dot(Vec3::Z).abs() > 0.9 { Vec3::X } else { Vec3::Z.cross(n) };
let x = (cand - n * cand.dot(n)).normalized()?;
Plane::new(origin, x, n.cross(x))
}
/// Named origin plane: `XY`, `XZ`, `YZ` (case-insensitive).
pub fn named(name: &str) -> Option<Plane> {
match name.to_ascii_uppercase().as_str() {
"XY" | "TOP" => Some(Plane::XY),
"XZ" | "FRONT" => Some(Plane::XZ),
"YZ" | "RIGHT" => Some(Plane::YZ),
_ => None,
}
}
pub fn normal(&self) -> Vec3 {
self.x.cross(self.y)
}
pub fn to_world(&self, p: Vec2) -> Vec3 {
self.origin + self.x * p.x + self.y * p.y
}
pub fn dir_to_world(&self, d: Vec2) -> Vec3 {
self.x * d.x + self.y * d.y
}
pub fn to_local(&self, p: Vec3) -> Vec2 {
let d = p - self.origin;
Vec2::new(d.dot(self.x), d.dot(self.y))
}
/// Signed distance of `p` above the plane (along the normal).
pub fn height(&self, p: Vec3) -> f64 {
(p - self.origin).dot(self.normal())
}
/// The plane moved along its normal by `d`.
pub fn offset(&self, d: f64) -> Plane {
Plane { origin: self.origin + self.normal() * d, ..*self }
}
/// Intersection of the ray `o + t·dir` with the plane (t may be negative).
pub fn intersect_ray(&self, o: Vec3, dir: Vec3) -> Option<Vec3> {
let n = self.normal();
let den = dir.dot(n);
if den.abs() < 1e-12 {
return None;
}
let t = (self.origin - o).dot(n) / den;
t.is_finite().then(|| o + dir * t)
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Planar profiles: closed loops of lines and circular arcs, and regions (an outer loop minus
//! holes). Sketches produce them; the kernel turns them into faces.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use crate::Vec2;
/// One boundary segment in sketch coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Seg2 {
Line {
a: Vec2,
b: Vec2,
},
/// Arc from `start` angle sweeping `sweep` radians (positive = counter-clockwise).
Arc {
center: Vec2,
radius: f64,
start: f64,
sweep: f64,
},
}
impl Seg2 {
pub fn start(&self) -> Vec2 {
match *self {
Seg2::Line { a, .. } => a,
Seg2::Arc { center, radius, start, .. } => center + Vec2::from_angle(start) * radius,
}
}
pub fn end(&self) -> Vec2 {
match *self {
Seg2::Line { b, .. } => b,
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep) * radius,
}
}
pub fn point_at(&self, t: f64) -> Vec2 {
match *self {
Seg2::Line { a, b } => a.lerp(b, t),
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep * t) * radius,
}
}
pub fn mid(&self) -> Vec2 {
self.point_at(0.5)
}
/// Unit tangent at the start (direction of travel).
pub fn start_tangent(&self) -> Vec2 {
match *self {
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start).perp() * sweep.signum(),
}
}
/// Unit tangent at the end (direction of travel).
pub fn end_tangent(&self) -> Vec2 {
match *self {
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start + sweep).perp() * sweep.signum(),
}
}
pub fn reversed(&self) -> Seg2 {
match *self {
Seg2::Line { a, b } => Seg2::Line { a: b, b: a },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep, sweep: -sweep },
}
}
pub fn length(&self) -> f64 {
match *self {
Seg2::Line { a, b } => a.dist(b),
Seg2::Arc { radius, sweep, .. } => (radius * sweep).abs(),
}
}
/// Contribution to the loop's signed area (shoelace integral ½∮(x dy − y dx)).
pub fn area_term(&self) -> f64 {
match *self {
Seg2::Line { a, b } => 0.5 * a.cross(b),
Seg2::Arc { radius, sweep, .. } => {
let (a, b) = (self.start(), self.end());
// Chord term plus the circular segment between chord and arc.
0.5 * a.cross(b) + 0.5 * radius * radius * (sweep - sweep.sin())
}
}
}
/// Polyline approximation (including both end points) with at most `tol` chord error.
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
match *self {
Seg2::Line { a, b } => vec![a, b],
Seg2::Arc { radius, sweep, .. } => {
let tol = tol.max(radius.abs() * 1e-6).max(1e-9);
let step = if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 };
let n = ((sweep.abs() / step.max(1e-6)).ceil() as usize).clamp(2, 4096);
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
}
}
}
}
/// A closed loop of segments, each starting where the previous one ends.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Loop2 {
pub segs: Vec<Seg2>,
}
impl Loop2 {
/// Full circle as two half arcs (counter-clockwise).
pub fn circle(center: Vec2, radius: f64) -> Loop2 {
Loop2 {
segs: vec![
Seg2::Arc { center, radius, start: 0.0, sweep: std::f64::consts::PI },
Seg2::Arc { center, radius, start: std::f64::consts::PI, sweep: std::f64::consts::PI },
],
}
}
pub fn polygon(pts: &[Vec2]) -> Loop2 {
let n = pts.len();
Loop2 { segs: (0..n).filter_map(|i| Some(Seg2::Line { a: *pts.get(i)?, b: *pts.get((i + 1) % n)? })).collect() }
}
/// Signed area: positive when counter-clockwise.
pub fn signed_area(&self) -> f64 {
self.segs.iter().map(Seg2::area_term).sum()
}
pub fn reversed(&self) -> Loop2 {
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
}
/// Counter-clockwise copy.
pub fn ccw(&self) -> Loop2 {
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
}
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
let mut out = Vec::new();
for s in &self.segs {
let p = s.polyline(tol);
out.extend(p.iter().take(p.len().saturating_sub(1)));
}
out
}
/// Even-odd point containment against the polyline approximation.
pub fn contains(&self, p: Vec2) -> bool {
let poly = self.polyline(1e-3);
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
if p.x < x {
inside = !inside;
}
}
}
inside
}
/// Area-weighted centroid (of the polyline approximation).
pub fn centroid(&self) -> Vec2 {
let poly = self.polyline(1e-3);
let n = poly.len();
let (mut a2, mut c) = (0.0, Vec2::ZERO);
for i in 0..n {
let (Some(p), Some(q)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
let k = p.cross(*q);
a2 += k;
c += (*p + *q) * k;
}
if a2.abs() < 1e-15 { poly.first().copied().unwrap_or_default() } else { c / (3.0 * a2) }
}
/// Closed within `tol`?
pub fn is_closed(&self, tol: f64) -> bool {
let n = self.segs.len();
n > 0
&& (0..n).all(|i| match (self.segs.get(i), self.segs.get((i + 1) % n)) {
(Some(a), Some(b)) => a.end().dist(b.start()) <= tol,
_ => false,
})
}
}
/// A planar region: outer loop (counter-clockwise) minus holes (clockwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region2 {
pub outer: Loop2,
pub holes: Vec<Loop2>,
}
impl Region2 {
pub fn area(&self) -> f64 {
self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::<f64>()
}
/// A point strictly inside the region (for picking and labels).
pub fn interior_point(&self) -> Vec2 {
let c = self.centroid();
if self.contains(c) {
return c;
}
// Scan a few horizontal lines for the midpoint of the widest inside span.
let poly = self.outer.polyline(1e-2);
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
for p in &poly {
y0 = y0.min(p.y);
y1 = y1.max(p.y);
}
for k in 1..16 {
let y = y0 + (y1 - y0) * k as f64 / 16.0;
let mut xs: Vec<f64> = Vec::new();
for l in std::iter::once(&self.outer).chain(&self.holes) {
let pl = l.polyline(1e-2);
let n = pl.len();
for i in 0..n {
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
if (a.y > y) != (b.y > y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
for w in xs.chunks(2) {
if let [a, b] = w {
let p = Vec2::new((a + b) * 0.5, y);
if self.contains(p) {
return p;
}
}
}
}
c
}
pub fn contains(&self, p: Vec2) -> bool {
self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p))
}
pub fn centroid(&self) -> Vec2 {
let ao = self.outer.signed_area().abs();
let mut c = self.outer.centroid() * ao;
let mut a = ao;
for h in &self.holes {
let ah = h.signed_area().abs();
c -= h.centroid() * ah;
a -= ah;
}
if a.abs() < 1e-15 { self.outer.centroid() } else { c / a }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn areas() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]);
assert!((sq.signed_area() - 12.0).abs() < 1e-12);
assert!((sq.reversed().signed_area() + 12.0).abs() < 1e-12);
let c = Loop2::circle(Vec2::new(5.0, 5.0), 2.0);
assert!((c.signed_area() - std::f64::consts::PI * 4.0).abs() < 1e-9);
assert!(c.is_closed(1e-9));
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 5.0).reversed()] };
assert!((r.area() - std::f64::consts::PI * 75.0).abs() < 1e-9);
let ip = r.interior_point();
assert!(r.contains(ip), "{ip:?}");
assert!(sq.contains(Vec2::new(1.0, 1.0)) && !sq.contains(Vec2::new(5.0, 1.0)));
assert!(sq.centroid().dist(Vec2::new(2.0, 1.5)) < 1e-9);
}
}
+64
View File
@@ -0,0 +1,64 @@
use crate::*;
fn cube_mesh(s: f64) -> Mesh {
let p = |x: f64, y: f64, z: f64| Vec3::new(x * s, y * s, z * s);
let positions = vec![p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.), p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)];
let triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
let n = triangles.len();
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![] }
}
#[test]
fn cube_measure() {
let m = cube_mesh(2.0).measure();
assert!((m.volume - 8.0).abs() < 1e-12);
assert!((m.area - 24.0).abs() < 1e-12);
assert!(m.centroid.dist(Vec3::new(1.0, 1.0, 1.0)) < 1e-12);
}
#[test]
fn raycast_hits_front_face() {
let m = cube_mesh(2.0);
let (t, _) = m.raycast(Vec3::new(1.0, 1.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).unwrap();
assert!((t - 8.0).abs() < 1e-9);
assert!(m.raycast(Vec3::new(5.0, 5.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).is_none());
}
#[test]
fn planes() {
assert_eq!(Plane::XY.normal(), Vec3::Z);
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
assert_eq!(Plane::YZ.normal(), Vec3::X);
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
assert!(Plane::new(Vec3::ZERO, Vec3::X, Vec3::X).is_none());
let f = Plane::from_normal(Vec3::ZERO, Vec3::new(0.0, 0.0, 3.0)).unwrap();
assert!((f.normal() - Vec3::Z).len() < 1e-12);
assert!(Plane::from_normal(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)).is_none());
}
#[test]
fn vec_ops_and_serde() {
let a = Vec3::new(1.0, 2.0, 3.0);
assert_eq!(a.cross(Vec3::X), Vec3::new(0.0, 3.0, -2.0));
assert_eq!(Vec2::new(3.0, 4.0).len(), 5.0);
assert!(Vec2::ZERO.normalized().is_none());
assert!(Vec3::new(0.5, 0.0, 0.0).dist_to_segment(Vec3::ZERO, Vec3::X) < 1e-12);
assert_eq!(serde_json::to_string(&a).unwrap(), "[1.0,2.0,3.0]");
let b: Vec2 = serde_json::from_str("[1,2]").unwrap();
assert_eq!(b, Vec2::new(1.0, 2.0));
}
#[test]
fn aabb() {
let mut b = Aabb3::EMPTY;
assert!(b.is_empty());
b.add(Vec3::new(1.0, 2.0, 3.0));
b.add(Vec3::new(f64::NAN, 0.0, 0.0));
b.add(Vec3::new(-1.0, 0.0, 0.0));
assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0));
}
+159
View File
@@ -0,0 +1,159 @@
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
use serde::{Deserialize, Serialize};
/// 2D vector / point (sketch space).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "[f64; 2]", into = "[f64; 2]")]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
/// 3D vector / point (model space, Z up).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "[f64; 3]", into = "[f64; 3]")]
pub struct Vec3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl From<[f64; 2]> for Vec2 {
fn from(a: [f64; 2]) -> Self {
Vec2::new(a[0], a[1])
}
}
impl From<Vec2> for [f64; 2] {
fn from(v: Vec2) -> Self {
[v.x, v.y]
}
}
impl From<[f64; 3]> for Vec3 {
fn from(a: [f64; 3]) -> Self {
Vec3::new(a[0], a[1], a[2])
}
}
impl From<Vec3> for [f64; 3] {
fn from(v: Vec3) -> Self {
[v.x, v.y, v.z]
}
}
impl Vec2 {
pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 };
pub const X: Vec2 = Vec2 { x: 1.0, y: 0.0 };
pub const Y: Vec2 = Vec2 { x: 0.0, y: 1.0 };
pub const fn new(x: f64, y: f64) -> Self {
Vec2 { x, y }
}
pub fn dot(self, o: Vec2) -> f64 {
self.x * o.x + self.y * o.y
}
/// z component of the 3D cross product.
pub fn cross(self, o: Vec2) -> f64 {
self.x * o.y - self.y * o.x
}
pub fn len(self) -> f64 {
self.x.hypot(self.y)
}
pub fn len2(self) -> f64 {
self.dot(self)
}
pub fn dist(self, o: Vec2) -> f64 {
(self - o).len()
}
pub fn normalized(self) -> Option<Vec2> {
let l = self.len();
(l > crate::EPS && l.is_finite()).then(|| self / l)
}
/// Rotated +90°.
pub fn perp(self) -> Vec2 {
Vec2::new(-self.y, self.x)
}
pub fn angle(self) -> f64 {
self.y.atan2(self.x)
}
pub fn from_angle(a: f64) -> Vec2 {
Vec2::new(a.cos(), a.sin())
}
pub fn lerp(self, o: Vec2, t: f64) -> Vec2 {
self + (o - self) * t
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite()
}
pub fn extend(self, z: f64) -> Vec3 {
Vec3::new(self.x, self.y, z)
}
}
impl Vec3 {
pub const ZERO: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 0.0 };
pub const X: Vec3 = Vec3 { x: 1.0, y: 0.0, z: 0.0 };
pub const Y: Vec3 = Vec3 { x: 0.0, y: 1.0, z: 0.0 };
pub const Z: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 1.0 };
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Vec3 { x, y, z }
}
pub fn dot(self, o: Vec3) -> f64 {
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: Vec3) -> Vec3 {
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
}
pub fn len(self) -> f64 {
self.len2().sqrt()
}
pub fn len2(self) -> f64 {
self.dot(self)
}
pub fn dist(self, o: Vec3) -> f64 {
(self - o).len()
}
pub fn normalized(self) -> Option<Vec3> {
let l = self.len();
(l > crate::EPS && l.is_finite()).then(|| self / l)
}
pub fn lerp(self, o: Vec3, t: f64) -> Vec3 {
self + (o - self) * t
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
pub fn min(self, o: Vec3) -> Vec3 {
Vec3::new(self.x.min(o.x), self.y.min(o.y), self.z.min(o.z))
}
pub fn max(self, o: Vec3) -> Vec3 {
Vec3::new(self.x.max(o.x), self.y.max(o.y), self.z.max(o.z))
}
/// Any unit vector perpendicular to `self` (which should be non-zero).
pub fn any_perp(self) -> Vec3 {
let a = if self.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
self.cross(a).normalized().unwrap_or(Vec3::Z)
}
pub fn to_f32(self) -> [f32; 3] {
[self.x as f32, self.y as f32, self.z as f32]
}
/// Distance from this point to the segment `a`–`b`.
pub fn dist_to_segment(self, a: Vec3, b: Vec3) -> f64 {
let ab = b - a;
let l2 = ab.len2();
let t = if l2 > 0.0 { ((self - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
self.dist(a + ab * t)
}
}
macro_rules! ops {
($t:ident, $($f:ident),+) => {
impl Add for $t { type Output = $t; fn add(self, o: $t) -> $t { $t { $($f: self.$f + o.$f),+ } } }
impl Sub for $t { type Output = $t; fn sub(self, o: $t) -> $t { $t { $($f: self.$f - o.$f),+ } } }
impl Mul<f64> for $t { type Output = $t; fn mul(self, k: f64) -> $t { $t { $($f: self.$f * k),+ } } }
impl Div<f64> for $t { type Output = $t; fn div(self, k: f64) -> $t { $t { $($f: self.$f / k),+ } } }
impl Neg for $t { type Output = $t; fn neg(self) -> $t { $t { $($f: -self.$f),+ } } }
impl AddAssign for $t { fn add_assign(&mut self, o: $t) { $(self.$f += o.$f;)+ } }
impl SubAssign for $t { fn sub_assign(&mut self, o: $t) { $(self.$f -= o.$f;)+ } }
};
}
ops!(Vec2, x, y);
ops!(Vec3, x, y, z);